feat(web): redesign Design Files panel + pick working directory from Home (#3358)

* feat(web): align Design Files panel with Claude reference UI

Redesign the Design Files panel into a continuous grouped table that
mirrors Claude's own Design Files surface: uppercase gray section bands
(Folders / Stylesheets / Scripts / Documents / Images …), flush
full-width rows with a colored icon block + bold name + type subtitle +
right-aligned timestamp, a clean single-line toolbar (breadcrumbs left,
New sketch / Paste / Upload right), a right preview card with a centered
Open action and `Modified … · size · EXT` stats, and a static
USEFUL INFO footer.

Removes the old table/sort-header/kind-filter/pagination/group-toggle
chrome and the conflicting legacy `.app .df-*` overrides in routines.css
that were trumping the panel's own stylesheet via specificity. Preserves
batch select/download/delete, rename, the row context menu,
live-artifacts, plugin-folders, drag-upload, and the reloading overlay.

Adds the designFiles.* section/kind/useful-info i18n keys across all
locales.

* feat: pick a working directory from Home before the project exists

Let users choose a code folder on the Home screen up front, instead of
only being able to replace it after a project is created. The HomeHero
composer gains a folder pill (pick / clear); the chosen path is threaded
through project creation and applied via the working-dir API once the
project id exists.

On desktop the working-dir POST is gated behind a host-minted token, so
add a `dialog:pick-working-dir` IPC handler that shows the native picker
and mints a single-use token bound to the folder, plus a
`pickHostWorkingDir()` bridge + types/normalizer in `@open-design/host`.
The renderer feature-detects the bridge and falls back to the web folder
dialog (no token) otherwise. Native dialogs now allow creating a new
folder inline (`createDirectory` / `ShowNewFolderButton`), and macOS uses
`choose folder` so the panel reliably takes key focus.

Wire the WorkingDirPill into the project composer footer and surface a
reloading state + breadcrumb root label to the Design Files panel while a
replace reindexes. Drop the WorkingDirPill "recent directories"
localStorage list in favor of a single Replace action.

* fix(web): preserve staged Home attachments when picking a working dir

Move the working-dir handoff ahead of the initial upload so staged
attachments land in the external folder instead of the temporary managed
root, and fall back to the browser folder dialog when the desktop host
build does not expose pickWorkingDir.

* fix(web): surface host pick errors and normalize Windows dir basenames

When the desktop host is present but cannot mint a working-dir token,
surface the error instead of falling back to the browser folder dialog —
the tokenless raw path would be rejected by the desktop auth gate as a
late create-time failure. Only the pure web path (no token gate) keeps
the dialog fallback. Also split the Home pill basename on both path
separators so Windows paths show the folder name, not the full path.

* fix(web,desktop): harden Home working-dir handoff against auth/token failures

Run the desktop-auth handshake before minting the Home working-dir token
so a missed startup registration fails up front instead of as a rejected
deferred POST. Surface a create-time error toast when the working-dir
handoff fails (expired ~60s token or daemon rejection) instead of
silently leaving the project in the managed root while the user believes
their folder was applied.

* fix(web): abort create on failed working-dir handoff and always show root crumb

When the pre-create working-dir handoff fails, short-circuit the rest of the
Home submit path (skip the staged-attachment upload and auto-send) so the first
run cannot proceed against the managed `.od/projects/<id>` tree the user did not
choose, and reword the error toast accordingly. Always render the Design Files
root breadcrumb, falling back to the `designFiles.crumbs` label when no
rootDirName exists, so the toolbar is not blank on the default managed-storage
view. Adds regressions for the short-circuit and default-root crumb cases.

Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)

* fix(web): normalize Windows separators in WorkingDirPill basename

shortPath() split only on '/', so a Windows working dir like C:\work\repo
rendered as the full absolute path in the footer pill instead of the
folder name. Split on both separators to match HomeHero/ProjectView, and
add a regression covering the backslash path.

Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
elihahah666
2026-06-04 05:48:57 +00:00
committed by GitHub
co-authored by qiongyu1999
parent fd8159216a
commit 6408937564
45 changed files with 1497 additions and 4969 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ const WINDOWS_FOLDER_DIALOG_SCRIPT = [
'$owner.Height = 1;',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;',
"$dialog.Description = 'Select a code folder to link';",
'$dialog.ShowNewFolderButton = $false;',
'$dialog.ShowNewFolderButton = $true;',
'try {',
' if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.SelectedPath }',
'} finally {',
+6
View File
@@ -3615,6 +3615,12 @@ function openNativeFolderDialog() {
return new Promise((resolve) => {
const platform = process.platform;
if (platform === 'darwin') {
// `choose folder` is handled specially by the system: it presents a fully
// interactive standard navigation panel that reliably takes key focus
// (unlike a JXA-driven NSOpenPanel from background-only osascript, which
// renders but can't be clicked). That standard panel already includes a
// "New Folder" button in the bottom-left, so users can create a folder
// inline without any extra wiring.
execFile(
'osascript',
['-e', 'POSIX path of (choose folder with prompt "Select a code folder to link")'],
@@ -27,7 +27,7 @@ describe('native folder dialog helpers', () => {
const script = buildWindowsFolderDialogCommand().args[3] ?? '';
expect(script).toContain('$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;');
expect(script).toContain('$dialog.ShowNewFolderButton = $false;');
expect(script).toContain('$dialog.ShowNewFolderButton = $true;');
expect(script).toContain('$dialog.ShowDialog($owner)');
expect(script).toContain('$owner.Dispose();');
});
+26
View File
@@ -9,6 +9,7 @@ import type {
OpenDesignHostFailure,
OpenDesignHostProjectImportResult,
OpenDesignHostProjectReplaceWorkingDirResult,
OpenDesignHostPickWorkingDirResult,
OpenDesignHostUpdaterActionOptions,
OpenDesignHostUpdaterStatusListener,
OpenDesignHostUpdaterStatusSnapshot,
@@ -94,6 +95,27 @@ function normalizeProjectReplaceWorkingDirResult(input: unknown): OpenDesignHost
return { baseDir, entryFile, ok: true };
}
function pickWorkingDirFailure(reason: string): OpenDesignHostPickWorkingDirResult {
return failure(reason);
}
function normalizePickWorkingDirResult(input: unknown): OpenDesignHostPickWorkingDirResult {
if (!isRecord(input)) return failure('desktop working-dir pick returned an invalid response', input);
if (input.ok !== true) {
if (input.canceled === true) return { canceled: true, ok: false };
return failure(
typeof input.reason === 'string' && input.reason.length > 0 ? input.reason : 'unknown failure',
input.details,
);
}
const baseDir = typeof input.baseDir === 'string' ? input.baseDir : null;
const token = typeof input.token === 'string' ? input.token : null;
if (baseDir == null || token == null) {
return failure('desktop working-dir pick did not include baseDir and token', input);
}
return { baseDir, ok: true, token };
}
function normalizeProjectImportResult(input: unknown): OpenDesignHostProjectImportResult {
if (!isRecord(input)) return failure('desktop import returned an invalid response', input);
if (input.ok !== true) {
@@ -159,6 +181,10 @@ const project = {
ipcRenderer.invoke('dialog:pick-and-replace-working-dir', { projectId })
.then(normalizeProjectReplaceWorkingDirResult)
.catch((error: unknown) => replaceWorkingDirFailure(reasonFromError(error))),
pickWorkingDir: (): Promise<OpenDesignHostPickWorkingDirResult> =>
ipcRenderer.invoke('dialog:pick-working-dir')
.then(normalizePickWorkingDirResult)
.catch((error: unknown) => pickWorkingDirFailure(reasonFromError(error))),
};
const shell = {
+79 -2
View File
@@ -577,6 +577,62 @@ export async function pickAndReplaceWorkingDir(
return { ok: true, response: body };
}
/**
* Pure helper for the `dialog:pick-working-dir` IPC handler (the Home,
* pre-create flow). Unlike `pickAndImportFolder` / `pickAndReplaceWorkingDir`,
* there is no project yet, so we cannot POST to the daemon to discover a
* `503 DESKTOP_AUTH_PENDING` and self-heal. The token we mint here is spent
* LATER, by the renderer, on `POST /api/projects/:id/working-dir` once the
* project exists.
*
* If the daemon missed its startup auth-registration window, that deferred
* POST is guaranteed to be rejected with `DESKTOP_AUTH_PENDING` — and the
* renderer's create flow surfaces that as a confusing late failure. To keep
* the Home picker on par with the import/replace flows' self-healing, we
* proactively run the desktop-auth handshake (`registerDesktopAuth`) BEFORE
* minting and returning the token, so the daemon already knows the secret by
* the time the renderer spends the token.
*
* Extracted from `createDesktopRuntime` so vitest can pin the
* DESKTOP_AUTH_PENDING re-registration path without booting Electron.
*/
export type MintHomeWorkingDirTokenDeps = {
baseDir: string;
desktopAuthSecret: Buffer;
/** Lazy desktop-auth handshake. Mirrors the import/replace flows. */
registerDesktopAuth?: () => Promise<boolean>;
/** Injected for tests; defaults to the production HMAC mint. */
mintToken?: (secret: Buffer, baseDir: string) => string;
};
export type MintHomeWorkingDirTokenResult =
| { baseDir: string; ok: true; token: string }
| { ok: false; reason: string };
export async function mintHomeWorkingDirToken(
deps: MintHomeWorkingDirTokenDeps,
): Promise<MintHomeWorkingDirTokenResult> {
const mint = deps.mintToken ?? mintImportToken;
const baseDir = deps.baseDir.trim();
if (baseDir.length === 0) {
return { ok: false, reason: "picker returned an empty path" };
}
// Ensure the daemon has the desktop-auth secret registered before we hand
// the renderer a token bound to it. A failed handshake here means the
// deferred working-dir POST would fail anyway, so report it now while the
// user is still in the picker rather than as a silent late create failure.
if (deps.registerDesktopAuth != null) {
const registered = await deps.registerDesktopAuth();
if (!registered) {
return {
ok: false,
reason: "desktop auth handshake with the daemon failed; please retry",
};
}
}
return { baseDir, ok: true, token: mint(deps.desktopAuthSecret, baseDir) };
}
const MAC_WINDOW_CHROME =
process.platform === "darwin"
? ({
@@ -1161,6 +1217,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
ipcMain.removeHandler("dialog:pick-folder");
ipcMain.removeHandler("dialog:pick-and-import");
ipcMain.removeHandler("dialog:pick-and-replace-working-dir");
ipcMain.removeHandler("dialog:pick-working-dir");
ipcMain.removeHandler("shell:open-external");
ipcMain.removeHandler("shell:open-path");
ipcMain.removeHandler("browser:clear-data");
@@ -1215,7 +1272,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
if (!apiBaseUrl) {
return { ok: false, reason: "daemon API URL not available" };
}
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
@@ -1258,7 +1315,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
if (!apiBaseUrl) {
return { ok: false, reason: "daemon API URL not available" };
}
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
@@ -1275,6 +1332,26 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
});
},
);
// Home-flow counterpart: the project does not exist yet, so we only show
// the native picker and mint a token bound to the chosen folder. The
// renderer threads { baseDir, token } back through project creation and
// spends the token on POST /api/projects/:id/working-dir once the project
// exists. Main remains the single source of filesystem paths crossing into
// the daemon (same trust boundary as dialog:pick-and-replace-working-dir).
ipcMain.handle("dialog:pick-working-dir", async () => {
if (options.desktopAuthSecret == null) {
return { ok: false, reason: "desktop auth secret not registered" };
}
const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
return await mintHomeWorkingDirToken({
baseDir: result.filePaths[0],
desktopAuthSecret: options.desktopAuthSecret,
registerDesktopAuth: options.registerDesktopAuthWithDaemon,
});
});
// shell.openPath opens an absolute filesystem path in the OS file
// manager (Finder / Explorer / Files). It resolves to '' on success
// and to a non-empty error string on failure (per Electron's
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { mintHomeWorkingDirToken } from "../../src/main/runtime.js";
const SECRET = Buffer.from("test-desktop-auth-secret");
describe("mintHomeWorkingDirToken", () => {
it("runs the desktop-auth handshake before minting the token", async () => {
const calls: string[] = [];
const registerDesktopAuth = vi.fn(async () => {
calls.push("register");
return true;
});
const mintToken = vi.fn((_secret: Buffer, baseDir: string) => {
calls.push("mint");
return `token:${baseDir}`;
});
const result = await mintHomeWorkingDirToken({
baseDir: "/Users/me/external",
desktopAuthSecret: SECRET,
registerDesktopAuth,
mintToken,
});
expect(result).toEqual({ baseDir: "/Users/me/external", ok: true, token: "token:/Users/me/external" });
// Handshake must complete before the token is minted, otherwise the token
// is bound to a secret the daemon may not yet know (DESKTOP_AUTH_PENDING).
expect(calls).toEqual(["register", "mint"]);
});
it("fails without minting when the desktop-auth handshake fails", async () => {
const registerDesktopAuth = vi.fn(async () => false);
const mintToken = vi.fn(() => "should-not-be-minted");
const result = await mintHomeWorkingDirToken({
baseDir: "/Users/me/external",
desktopAuthSecret: SECRET,
registerDesktopAuth,
mintToken,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/desktop auth handshake/i);
}
// A guaranteed-unknown token is worse than a clear up-front failure, so we
// must not mint when the handshake did not succeed.
expect(mintToken).not.toHaveBeenCalled();
});
it("rejects an empty picked path", async () => {
const mintToken = vi.fn(() => "x");
const result = await mintHomeWorkingDirToken({
baseDir: " ",
desktopAuthSecret: SECRET,
registerDesktopAuth: vi.fn(async () => true),
mintToken,
});
expect(result.ok).toBe(false);
expect(mintToken).not.toHaveBeenCalled();
});
it("trims the picked path before binding the token to it", async () => {
const mintToken = vi.fn((_secret: Buffer, baseDir: string) => `token:${baseDir}`);
const result = await mintHomeWorkingDirToken({
baseDir: " /Users/me/external ",
desktopAuthSecret: SECRET,
registerDesktopAuth: vi.fn(async () => true),
mintToken,
});
expect(result).toEqual({ baseDir: "/Users/me/external", ok: true, token: "token:/Users/me/external" });
expect(mintToken).toHaveBeenCalledWith(SECRET, "/Users/me/external");
});
});
+51 -1
View File
@@ -20,6 +20,7 @@ import { MarketplaceView } from './components/MarketplaceView';
import { PluginDetailView } from './components/PluginDetailView';
import type { CreateInput, ImportClaudeDesignOutcome } from './components/NewProjectPanel';
import { MemoryToast } from './components/MemoryToast';
import { Toast } from './components/Toast';
import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay';
import { buildPetTaskCenter } from './components/pet/taskCenter';
import { migrateCustomPetAtlas } from './components/pet/pets';
@@ -51,6 +52,7 @@ import {
fetchPromptTemplates,
fetchSkills,
uploadProjectFiles,
replaceProjectWorkingDir,
} from './providers/registry';
import {
RUNS_CHANGED_EVENT,
@@ -316,6 +318,11 @@ function AppInner() {
const latestPersistedConfigRef = useRef(config);
latestPersistedConfigRef.current = config;
const [settingsOpen, setSettingsOpen] = useState(false);
// Surfaced when a Home-picked working dir could not be applied to a freshly
// created project (expired/invalid desktop token, daemon rejection). Without
// this the failure was swallowed and the user believed their folder was in
// effect while the project actually stayed in the managed root.
const [workingDirError, setWorkingDirError] = useState<string | null>(null);
const [settingsWelcome, setSettingsWelcome] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
const [settingsHighlight, setSettingsHighlight] = useState<SettingsHighlight>(null);
@@ -1159,6 +1166,7 @@ function AppInner() {
autoSendFirstMessage?: boolean;
requestId?: string;
pendingFiles?: File[];
userWorkingDirToken?: string;
},
): Promise<boolean> => {
// Honor an explicit `null` design system — the create panel defaults
@@ -1206,8 +1214,42 @@ function AppInner() {
const pendingFiles = Array.isArray(input.pendingFiles)
? input.pendingFiles.filter((file): file is File => file instanceof File)
: [];
// Flip the project onto the user-picked working directory BEFORE
// uploading staged Home attachments. `replaceProjectWorkingDir` changes
// `metadata.baseDir`, so the project starts reading from the external
// folder. If we uploaded first, the staged files would land in the
// temporary managed `.od/projects/<id>` root and then silently vanish
// from Design Files and the first auto-send context once the working
// dir flips. Doing the handoff first means the initial upload lands in
// the final tree.
const userWorkingDir = input.metadata?.userWorkingDir;
let workingDirHandoffFailed = false;
if (userWorkingDir) {
try {
await replaceProjectWorkingDir(
result.project.id,
userWorkingDir,
input.userWorkingDirToken,
);
} catch (err) {
// The desktop working-dir token is short-lived (~60s TTL); if the
// user lingered on Home or the POST was otherwise rejected, the
// handoff fails AFTER the project already exists. Do NOT swallow
// this and do NOT proceed: uploading staged attachments or
// auto-sending the first message would target the managed
// `.od/projects/<id>` root the user did not choose. Mark the
// handoff as failed so the upload + auto-send branches below are
// skipped, then surface a create-time error so the user can
// re-pick the working directory from inside the project.
console.warn('Failed to set working directory for new project', userWorkingDir, err);
workingDirHandoffFailed = true;
setWorkingDirError(
`Couldn't apply the chosen folder "${userWorkingDir}". The project was created in the default location — re-pick the working directory from the project before uploading files or sending a message.`,
);
}
}
let firstMessageAttachments: ChatAttachment[] = [];
if (pendingFiles.length > 0) {
if (!workingDirHandoffFailed && pendingFiles.length > 0) {
// Home composer attaches stay client-side until submit lands a
// project; the actual upload happens here. v2 doc wants one
// file_upload_result per surface — `page_name='home'` /
@@ -1250,6 +1292,7 @@ function AppInner() {
// pre-filling the composer. Scoped to sessionStorage so a page
// reload after the run has started does not refire.
if (
!workingDirHandoffFailed &&
input.autoSendFirstMessage &&
(derivedPendingPrompt !== undefined || firstMessageAttachments.length > 0)
) {
@@ -1997,6 +2040,13 @@ function AppInner() {
) : null}
</AnimatePresence>
<MemoryToast onOpenMemory={() => openSettings('memory')} />
{workingDirError ? (
<Toast
message={workingDirError}
role="alert"
onDismiss={() => setWorkingDirError(null)}
/>
) : null}
{/* First-run privacy consent banner. It waits for daemon config
hydration because privacyDecisionAt is daemon-owned and stripped
from localStorage. It waits for `onboardingCompleted` so first-run
File diff suppressed because it is too large Load Diff
+1
View File
@@ -568,6 +568,7 @@ export function EntryShell({
...(payload.attachments && payload.attachments.length > 0
? { pendingFiles: payload.attachments }
: {}),
...(payload.workingDirToken ? { userWorkingDirToken: payload.workingDirToken } : {}),
autoSendFirstMessage: true,
});
}
+10 -18
View File
@@ -93,6 +93,12 @@ import type { ChatMessage } from '../types';
interface Props {
projectId: string;
projectKind: TrackingProjectKind;
// Basename of the project's chosen working directory (e.g. "openclaw").
// Threaded to DesignFilesPanel as the breadcrumb root label. Undefined for
// default-storage projects.
rootDirName?: string;
// True while a working-dir replace is reindexing; shows a loading state.
reloading?: boolean;
/** Absolute on-disk project directory (from GET /api/projects/:id). Used by
* the Design Files panel's "copy absolute path" action. */
resolvedDir?: string | null;
@@ -323,6 +329,8 @@ const DESIGN_SYSTEM_IMAGE_OR_FONT_EXTENSIONS = /\.(svg|png|jpe?g|gif|webp|avif|i
export function FileWorkspace({
projectId,
projectKind,
rootDirName,
reloading,
resolvedDir,
files,
liveArtifacts,
@@ -2023,12 +2031,11 @@ export function FileWorkspace({
<DesignFilesPanel
key={projectId}
projectId={projectId}
resolvedDir={resolvedDir ?? undefined}
rootDirName={rootDirName}
reloading={reloading}
files={visibleFiles}
folders={projectFolders}
liveArtifacts={liveArtifactEntries}
onRefreshFiles={onRefreshFiles}
onCurrentDirChange={setUploadDir}
onOpenFile={openFile}
onOpenLiveArtifact={(tabId) => openFile(tabId)}
onRenameFile={handleRename}
@@ -2057,21 +2064,6 @@ export function FileWorkspace({
fileInputRef.current?.click();
}}
onUploadFiles={(picked) => void uploadFiles(picked)}
onCreateFolder={async (name) => {
const folder = await createProjectFolder(projectId, name);
if (folder) {
await refreshProjectFolders();
}
return folder;
}}
onDeleteFolder={async (folderPath) => {
const ok = await deleteProjectFolder(projectId, folderPath);
if (ok) {
await onRefreshFiles();
await refreshProjectFolders();
}
return ok;
}}
onPaste={() => {
trackFileManagerClick(analytics.track, {
page_name: 'file_manager',
+31
View File
@@ -132,6 +132,9 @@ interface Props {
contextItemCount: number;
error: string | null;
showActivePluginChip?: boolean;
workingDir?: string | null;
onPickWorkingDir?: () => void;
onClearWorkingDir?: () => void;
onExamplePromptStatusChange?: (info: ExamplePromptInfo | null) => void;
}
@@ -236,6 +239,9 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
contextItemCount,
error,
showActivePluginChip = true,
workingDir = null,
onPickWorkingDir,
onClearWorkingDir,
onExamplePromptStatusChange,
},
ref,
@@ -1203,6 +1209,31 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
>
<Icon name="attach" size={15} />
</button>
{onPickWorkingDir ? (
<div className="home-hero__working-dir-wrap">
<button
type="button"
className={`home-hero__working-dir${workingDir ? ' picked' : ''}`}
onClick={onPickWorkingDir}
title={workingDir ?? t('workingDirPicker.select')}
>
<Icon name="folder" size={13} />
<span>
{workingDir ? workingDir.split(/[/\\]/).filter(Boolean).pop() : t('workingDirPicker.select')}
</span>
</button>
{workingDir ? (
<button
type="button"
className="home-hero__working-dir-clear"
onClick={() => onClearWorkingDir?.()}
aria-label={t('workingDirPicker.clearAria')}
>
<Icon name="close" size={10} />
</button>
) : null}
</div>
) : null}
<SessionModeToggle
mode={sessionMode}
onChange={onSessionModeChange}
+49 -1
View File
@@ -42,7 +42,8 @@ import {
localizeSkillPrompt,
} from '../i18n/content';
import { fetchElevenLabsVoiceOptions } from '../providers/elevenlabs-voices';
import { fetchProjectFiles, projectFileUrl } from '../providers/registry';
import { fetchProjectFiles, openFolderDialog, projectFileUrl } from '../providers/registry';
import { isOpenDesignHostAvailable, pickHostWorkingDir } from '@open-design/host';
import type {
DesignSystemSummary,
Project,
@@ -239,6 +240,11 @@ export function HomeView({
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
const [selectedConnectorContexts, setSelectedConnectorContexts] = useState<SelectedConnectorContext[]>([]);
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
const [workingDir, setWorkingDir] = useState<string | null>(null);
// Token paired with `workingDir` when picked through the desktop host's
// native dialog. Spent on the post-creation working-dir POST so the
// daemon's desktop-auth gate accepts the path. Null for web picks.
const [workingDirToken, setWorkingDirToken] = useState<string | null>(null);
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
const [mcpLoading, setMcpLoading] = useState(true);
const [prompt, setPrompt] = useState('');
@@ -923,6 +929,40 @@ export function HomeView({
setStagedFiles((current) => current.filter((_, i) => i !== index));
}
async function handlePickWorkingDir() {
// On desktop the working-dir POST is gated behind a host-minted token, so
// pick through the host bridge to capture { baseDir, token } together.
if (isOpenDesignHostAvailable()) {
const result = await pickHostWorkingDir();
if (result.ok) {
setWorkingDir(result.baseDir);
setWorkingDirToken(result.token);
return;
}
// The user explicitly cancelled the host picker — respect that and do
// not pop a second dialog.
if ('canceled' in result && result.canceled) return;
// The host is present but could not service the pick (mixed-version
// upgrade where the preload lacks `project.pickWorkingDir`, or a host
// error). We must NOT fall back to openFolderDialog() here: the browser
// dialog yields a raw path with no host-minted token, so the later
// POST /api/projects/:id/working-dir would be rejected by the desktop
// auth gate and surface as a confusing late create-time failure.
// Surface the host error instead and keep the existing working dir.
setError(
`Couldn't open the folder picker (${'reason' in result ? result.reason : 'host unavailable'}). Please update Open Design and try again.`,
);
return;
}
// Pure web path: no desktop host, so there is no token gate — the raw
// browser folder path is the expected, working input.
const picked = await openFolderDialog();
if (picked) {
setWorkingDir(picked);
setWorkingDirToken(null);
}
}
function updateActiveInputs(next: Record<string, unknown>) {
if (!active) return;
const normalized = active.mediaSurface
@@ -1296,6 +1336,8 @@ export function HomeView({
contextMcpServers,
contextConnectors,
attachments: stagedFiles,
...(workingDir ? { workingDir } : {}),
...(workingDirToken ? { workingDirToken } : {}),
conversationMode: sessionMode,
...(() => {
if (!examplePromptInfoRef.current) return {};
@@ -1369,6 +1411,12 @@ export function HomeView({
onPickChip={pickChip}
contextItemCount={contextItemCount}
error={error}
workingDir={workingDir}
onPickWorkingDir={handlePickWorkingDir}
onClearWorkingDir={() => {
setWorkingDir(null);
setWorkingDirToken(null);
}}
onExamplePromptStatusChange={handleExamplePromptStatusChange}
/>
@@ -42,6 +42,11 @@ export interface PluginLoopSubmit {
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
projectMetadata?: ProjectMetadata | null;
workingDir?: string | null;
// Single-use desktop token minted for `workingDir` when the folder was
// chosen through the host's native picker. Spent (not persisted) on the
// post-creation working-dir POST so the daemon's desktop-auth gate accepts
// it. Null/absent for web picks (gate inactive) or no selection.
workingDirToken?: string | null;
conversationMode?: ChatSessionMode;
// Files staged on Home before the project exists. App uploads them
// into the created project's Design Files before the first auto-send.
+33
View File
@@ -161,6 +161,7 @@ import { ProjectDesignSystemPicker } from './ProjectDesignSystemPicker';
import { PluginDetailsModal } from './PluginDetailsModal';
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { ChatPane } from './ChatPane';
import { WorkingDirPill } from './WorkingDirPill';
import type { ChatSendMeta } from './ChatComposer';
import {
CritiqueTheaterMount,
@@ -768,6 +769,10 @@ export function ProjectView({
const [audioVoiceOptionsError, setAudioVoiceOptionsError] = useState<string | null>(null);
const [artifact, setArtifact] = useState<Artifact | null>(null);
const [filesRefresh, setFilesRefresh] = useState(0);
// True while a working-dir replace is reindexing the new folder. Surfaced
// to the Design Files panel so the file list shows a loading state instead
// of silently sitting on the old tree for the few seconds the scan takes.
const [workingDirReplacing, setWorkingDirReplacing] = useState(false);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const projectFilesRef = useRef<ProjectFile[]>([]);
const [liveArtifacts, setLiveArtifacts] = useState<LiveArtifactSummary[]>([]);
@@ -5157,6 +5162,14 @@ export function ProjectView({
<FileWorkspace
projectId={project.id}
projectKind={projectKindToTracking(project.metadata?.kind) ?? 'prototype'}
rootDirName={(() => {
const baseDir =
projectDetail.project?.metadata?.baseDir ?? project.metadata?.baseDir;
return typeof baseDir === 'string'
? baseDir.split(/[/\\]/).filter(Boolean).pop()
: undefined;
})()}
reloading={workingDirReplacing}
resolvedDir={projectDetail.resolvedDir}
files={projectFiles}
liveArtifacts={liveArtifacts}
@@ -5217,6 +5230,26 @@ export function ProjectView({
conversationId={activeConversationId}
headerActions={(
<>
<WorkingDirPill
projectId={project.id}
resolvedDir={projectDetail.resolvedDir}
onReplaced={({ project: updated }) => {
if (updated) onProjectChange(updated);
// The new working dir has a different file tree, so the
// current listing, breadcrumb nav, and open tabs are all
// stale. Refetch files; DesignFilesPanel's self-heal then
// drops the now-unmatched currentDir back to root.
// projectDetail.refresh() repulls resolvedDir so the
// breadcrumb root + pill show the new folder name even on
// the Electron path, which reports no updated project.
setWorkingDirReplacing(true);
refreshFilesAndDesignMd();
void Promise.all([
refreshWorkspaceItems(),
projectDetail.refresh(),
]).finally(() => setWorkingDirReplacing(false));
}}
/>
<EntrySettingsMenu
config={config}
onThemeChange={handleThemeChange}
+1 -58
View File
@@ -12,9 +12,6 @@ import { useT } from '../i18n';
import type { Project } from '../types';
import { Icon } from './Icon';
const RECENT_DIRS_KEY = 'open-design:recent-working-dirs';
const RECENT_DIRS_LIMIT = 6;
interface Props {
projectId: string;
resolvedDir?: string | null;
@@ -25,30 +22,8 @@ interface Props {
}) => void;
}
function readRecent(): string[] {
try {
const raw = window.localStorage.getItem(RECENT_DIRS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((value): value is string => typeof value === 'string').slice(0, RECENT_DIRS_LIMIT);
} catch {
return [];
}
}
function pushRecent(dir: string): void {
try {
const prev = readRecent();
const next = [dir, ...prev.filter((item) => item !== dir)].slice(0, RECENT_DIRS_LIMIT);
window.localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(next));
} catch {
// Best-effort local convenience only.
}
}
function shortPath(dir: string): string {
return dir.split('/').filter(Boolean).slice(-1)[0] ?? dir;
return dir.split(/[/\\]/).filter(Boolean).slice(-1)[0] ?? dir;
}
export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onReplaced }: Props) {
@@ -56,7 +31,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recents, setRecents] = useState<string[]>(() => readRecent());
const [fetchedDir, setFetchedDir] = useState<string | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
@@ -100,17 +74,12 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
};
}, [open]);
useEffect(() => {
if (open) setRecents(readRecent());
}, [open]);
async function applyDir(dir: string) {
setError(null);
setBusy(true);
setOpen(false);
try {
const result = await replaceProjectWorkingDir(projectId, dir);
pushRecent(result.baseDir);
setFetchedDir(result.baseDir);
onReplaced?.({
baseDir: result.baseDir,
@@ -132,7 +101,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
try {
const result = await pickAndReplaceHostProjectWorkingDir(projectId);
if (result.ok) {
pushRecent(result.baseDir);
setFetchedDir(result.baseDir);
onReplaced?.({
baseDir: result.baseDir,
@@ -191,8 +159,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
}
}
const showRecents = !isOpenDesignHostAvailable();
return (
<div
ref={wrapRef}
@@ -244,29 +210,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
<Icon name="folder" size={12} />
<span>{t('workingDirPicker.replace')}</span>
</button>
{showRecents && recents.filter((item) => item !== resolvedDir).length > 0 ? (
<>
<div className="working-dir-pill-menu-divider" />
<div className="working-dir-pill-menu-section">{t('workingDirPicker.recent')}</div>
{recents
.filter((item) => item !== resolvedDir)
.map((dir) => (
<button
key={dir}
type="button"
role="menuitem"
className="working-dir-pill-menu-item small"
title={dir}
onClick={() => void applyDir(dir)}
>
<Icon name="folder" size={12} />
<span className="working-dir-pill-menu-recent">
{dir.split('/').filter(Boolean).slice(-2).join('/')}
</span>
</button>
))}
</>
) : null}
{error ? (
<>
<div className="working-dir-pill-menu-divider" />
+8 -1
View File
@@ -32,7 +32,7 @@ export const ar: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1099,6 +1099,13 @@ export const ar: Dict = {
'designFiles.next': 'التالي',
'designFiles.jumpToPage': 'انتقل إلى الصفحة',
'designFiles.pageInfo': '{start}–{end} من {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'فتح ملف…',
'quickSwitcher.empty': 'لا توجد ملفات في هذا المشروع',
'quickSwitcher.noMatches': 'لا توجد نتائج',
+8 -1
View File
@@ -32,7 +32,7 @@ export const de: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -987,6 +987,13 @@ export const de: Dict = {
'designFiles.next': 'Weiter',
'designFiles.jumpToPage': 'Gehe zu Seite',
'designFiles.pageInfo': '{start}–{end} von {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Datei öffnen…',
'quickSwitcher.empty': 'Keine Dateien in diesem Projekt',
'quickSwitcher.noMatches': 'Keine Treffer',
+8 -1
View File
@@ -558,7 +558,7 @@ export const en: Dict = {
'workingDirPicker.openUnavailable': 'Open this project in the desktop app to show the folder.',
'workingDirPicker.openFailed': 'Could not show this folder',
'workingDirPicker.showInFileManager': 'Show in file manager',
'workingDirPicker.replace': 'Clear and replace directory…',
'workingDirPicker.replace': 'Replace directory…',
'workingDirPicker.recent': 'Recent directories',
'handoff.toTarget': 'Hand off to {target}',
'handoff.openInTarget': 'Open in {target}',
@@ -1908,6 +1908,13 @@ export const en: Dict = {
'designFiles.next': 'Next',
'designFiles.jumpToPage': 'Go to page',
'designFiles.pageInfo': '{start}–{end} of {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Open tab or file…',
'quickSwitcher.empty': 'No files in this project',
'quickSwitcher.noMatches': 'No matches',
+8 -1
View File
@@ -32,7 +32,7 @@ export const esES: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -988,6 +988,13 @@ export const esES: Dict = {
'designFiles.next': 'Siguiente',
'designFiles.jumpToPage': 'Ir a la página',
'designFiles.pageInfo': '{start}–{end} de {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Abrir archivo…',
'quickSwitcher.empty': 'No hay archivos en este proyecto',
'quickSwitcher.noMatches': 'Sin resultados',
+8 -1
View File
@@ -32,7 +32,7 @@ export const fa: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1123,6 +1123,13 @@ export const fa: Dict = {
'designFiles.next': 'بعدی',
'designFiles.jumpToPage': 'برو به صفحه',
'designFiles.pageInfo': '{start}–{end} از {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'باز کردن فایل…',
'quickSwitcher.empty': 'هیچ فایلی در این پروژه نیست',
'quickSwitcher.noMatches': 'بدون نتیجه',
+8 -1
View File
@@ -505,7 +505,7 @@ export const fr: Dict = {
'workingDirPicker.openUnavailable': 'Ouvrez ce projet dans l’app desktop pour afficher le dossier.',
'workingDirPicker.openFailed': 'Impossible d’afficher ce dossier',
'workingDirPicker.showInFileManager': 'Afficher dans le gestionnaire de fichiers',
'workingDirPicker.replace': 'Effacer et remplacer le dossier…',
'workingDirPicker.replace': 'Remplacer le dossier…',
'workingDirPicker.recent': 'Dossiers récents',
'handoff.toTarget': 'Transférer vers {target}',
'handoff.action': 'Transférer',
@@ -1632,6 +1632,13 @@ export const fr: Dict = {
'designFiles.next': 'Suivant',
'designFiles.jumpToPage': 'Aller à la page',
'designFiles.pageInfo': '{start}–{end} sur {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Ouvrir un fichier…',
'quickSwitcher.empty': 'Aucun fichier dans ce projet',
'quickSwitcher.noMatches': 'Aucun résultat',
+8 -1
View File
@@ -32,7 +32,7 @@ export const hu: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1099,6 +1099,13 @@ export const hu: Dict = {
'designFiles.next': 'Következő',
'designFiles.jumpToPage': 'Ugrás oldalra',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Fájl megnyitása…',
'quickSwitcher.empty': 'Nincsenek fájlok ebben a projektben',
'quickSwitcher.noMatches': 'Nincs találat',
+8 -1
View File
@@ -32,7 +32,7 @@ export const id: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1211,6 +1211,13 @@ export const id: Dict = {
'designFiles.next': 'Berikutnya',
'designFiles.jumpToPage': 'Lompat ke halaman',
'designFiles.pageInfo': '{start}–{end} dari {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Buka file...',
'quickSwitcher.empty': 'Tidak ada file di proyek ini',
+8 -1
View File
@@ -32,7 +32,7 @@ export const it: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1004,6 +1004,13 @@ export const it: Dict = {
'designFiles.next': 'Successivo',
'designFiles.jumpToPage': 'Vai alla pagina',
'designFiles.pageInfo': '{start}–{end} su {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Apri un file…',
'quickSwitcher.empty': 'Nessun file in questo progetto',
'quickSwitcher.noMatches': 'Nessun risultato',
+8 -1
View File
@@ -32,7 +32,7 @@ export const ja: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -986,6 +986,13 @@ export const ja: Dict = {
'designFiles.next': '次へ',
'designFiles.jumpToPage': 'ページにジャンプ',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'ファイルを開く…',
'quickSwitcher.empty': 'このプロジェクトにファイルがありません',
'quickSwitcher.noMatches': '一致なし',
+8 -1
View File
@@ -32,7 +32,7 @@ export const ko: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1099,6 +1099,13 @@ export const ko: Dict = {
'designFiles.next': '다음',
'designFiles.jumpToPage': '페이지로 이동',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': '파일 열기…',
'quickSwitcher.empty': '이 프로젝트에 파일이 없습니다',
'quickSwitcher.noMatches': '일치 항목 없음',
+8 -1
View File
@@ -32,7 +32,7 @@ export const pl: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1099,6 +1099,13 @@ export const pl: Dict = {
'designFiles.next': 'Następna',
'designFiles.jumpToPage': 'Przejdź do strony',
'designFiles.pageInfo': '{start}–{end} z {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Otwórz plik…',
'quickSwitcher.empty': 'Brak plików w tym projekcie',
'quickSwitcher.noMatches': 'Brak wyników',
+8 -1
View File
@@ -32,7 +32,7 @@ export const ptBR: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1122,6 +1122,13 @@ export const ptBR: Dict = {
'designFiles.next': 'Próximo',
'designFiles.jumpToPage': 'Ir para a página',
'designFiles.pageInfo': '{start}–{end} de {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Abrir arquivo…',
'quickSwitcher.empty': 'Nenhum arquivo neste projeto',
'quickSwitcher.noMatches': 'Sem resultados',
+8 -1
View File
@@ -32,7 +32,7 @@ export const ru: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1122,6 +1122,13 @@ export const ru: Dict = {
'designFiles.next': 'Вперёд',
'designFiles.jumpToPage': 'Перейти на страницу',
'designFiles.pageInfo': '{start}–{end} из {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Открыть файл…',
'quickSwitcher.empty': 'В проекте нет файлов',
'quickSwitcher.noMatches': 'Нет совпадений',
+8 -1
View File
@@ -32,7 +32,7 @@ export const th: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1037,6 +1037,13 @@ export const th: Dict = {
'designFiles.next': 'ถัดไป',
'designFiles.jumpToPage': 'ไปหน้าที่',
'designFiles.pageInfo': '{start}–{end} รวม {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'ค้นหาและเปิด…',
'quickSwitcher.empty': 'ไร้ไฟล์ที่หาได้',
'quickSwitcher.noMatches': 'ไม่เจอรายการที่ตรง',
+8 -1
View File
@@ -32,7 +32,7 @@ export const tr: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1086,6 +1086,13 @@ export const tr: Dict = {
'designFiles.next': 'Sonraki',
'designFiles.jumpToPage': 'Sayfaya git',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'quickSwitcher.placeholder': 'Dosya aç…',
'quickSwitcher.empty': 'Bu projede dosya yok',
'quickSwitcher.noMatches': 'Eşleşme yok',
+8 -1
View File
@@ -32,7 +32,7 @@ export const uk: Dict = {
'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.",
'workingDirPicker.openFailed': "Could not show this folder",
'workingDirPicker.showInFileManager': "Show in file manager",
'workingDirPicker.replace': "Clear and replace directory…",
'workingDirPicker.replace': "Replace directory…",
'workingDirPicker.recent': "Recent directories",
'handoff.toTarget': 'Hand off to {target}',
'handoff.action': 'Hand off',
@@ -1123,6 +1123,13 @@ export const uk: Dict = {
'designFiles.next': 'Далі',
'designFiles.jumpToPage': 'Перейти на сторінку',
'designFiles.pageInfo': '{start}–{end} із {total}',
'designFiles.sectionFolders': 'Folders',
'designFiles.sectionStylesheets': 'Stylesheets',
'designFiles.sectionDocuments': 'Documents',
'designFiles.kindStylesheet': 'Stylesheet',
'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': 'Useful info',
'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.',
'liveArtifact.refresh.button': 'Оновити',
'liveArtifact.refresh.buttonTitle': 'Оновити live-артефакт',
'liveArtifact.refresh.loadingTitle': 'Оновлення live-артефакту…',
+8 -1
View File
@@ -557,7 +557,7 @@ export const zhCN: Dict = {
'workingDirPicker.openUnavailable': '请在桌面版中打开此项目以显示文件夹。',
'workingDirPicker.openFailed': '无法显示此文件夹',
'workingDirPicker.showInFileManager': '在文件管理器中显示',
'workingDirPicker.replace': '清空并替换目录…',
'workingDirPicker.replace': '替换目录…',
'workingDirPicker.recent': '最近使用的目录',
'handoff.toTarget': '交付给 {target}',
'handoff.openInTarget': '在 {target} 中打开',
@@ -1881,6 +1881,13 @@ export const zhCN: Dict = {
'designFiles.next': '下一页',
'designFiles.jumpToPage': '跳转到页面',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': '文件夹',
'designFiles.sectionStylesheets': '样式表',
'designFiles.sectionDocuments': '文档',
'designFiles.kindStylesheet': '样式表',
'designFiles.modifiedExt': '修改于 {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': '使用提示',
'designFiles.usefulInfoTip': '双击任意文件可在独立标签页中打开。',
'quickSwitcher.placeholder': '打开标签页或文件…',
'quickSwitcher.empty': '此项目中没有文件',
'quickSwitcher.noMatches': '无匹配项',
+8 -1
View File
@@ -32,7 +32,7 @@ export const zhTW: Dict = {
'workingDirPicker.openUnavailable': "請在桌面版中開啟此專案以顯示資料夾。",
'workingDirPicker.openFailed': "無法顯示此資料夾",
'workingDirPicker.showInFileManager': "在檔案管理器中顯示",
'workingDirPicker.replace': "清空並替換目錄…",
'workingDirPicker.replace': "替換目錄…",
'workingDirPicker.recent': "最近使用的目錄",
'handoff.toTarget': '交付給 {target}',
'handoff.openInTarget': '在 {target} 中開啟',
@@ -1475,6 +1475,13 @@ export const zhTW: Dict = {
'designFiles.next': '下一頁',
'designFiles.jumpToPage': '跳轉到頁面',
'designFiles.pageInfo': '{start}–{end} / {total}',
'designFiles.sectionFolders': '資料夾',
'designFiles.sectionStylesheets': '樣式表',
'designFiles.sectionDocuments': '文件',
'designFiles.kindStylesheet': '樣式表',
'designFiles.modifiedExt': '修改於 {time} · {size} · {ext}',
'designFiles.usefulInfoLabel': '使用提示',
'designFiles.usefulInfoTip': '雙擊任意檔案即可在獨立分頁中開啟。',
'quickSwitcher.placeholder': '開啟分頁或檔案…',
'quickSwitcher.empty': '此專案中沒有檔案',
'quickSwitcher.noMatches': '無符合項目',
+7
View File
@@ -2245,6 +2245,13 @@ export interface Dict {
'designFiles.next': string;
'designFiles.jumpToPage': string;
'designFiles.pageInfo': string;
'designFiles.sectionFolders': string;
'designFiles.sectionStylesheets': string;
'designFiles.sectionDocuments': string;
'designFiles.kindStylesheet': string;
'designFiles.modifiedExt': string;
'designFiles.usefulInfoLabel': string;
'designFiles.usefulInfoTip': string;
'quickSwitcher.placeholder': string;
'quickSwitcher.empty': string;
'quickSwitcher.noMatches': string;
+9 -8
View File
@@ -768,6 +768,7 @@
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
overflow: visible;
}
.home-hero__tool {
appearance: none;
@@ -811,17 +812,16 @@
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
flex-shrink: 0;
}
.home-hero__working-dir {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
gap: 4px;
height: 32px;
max-width: 220px;
min-width: 0;
padding: 0 12px;
max-width: 180px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
@@ -943,10 +943,11 @@
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
gap: 4px;
max-width: 220px;
min-width: 0;
padding: 0 12px;
flex-shrink: 1;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
@@ -988,7 +989,7 @@
outline: none;
}
.home-hero__footer-option input {
width: 150px;
width: 100px;
}
.home-hero__footer-option--select {
padding-right: 11px;
-240
View File
@@ -1555,246 +1555,6 @@
box-shadow: none;
}
.app .df-panel,
.app .df-main {
background: var(--bg-panel);
}
.app .df-panel:not(.no-preview) {
grid-template-columns: minmax(320px, 1fr) minmax(320px, 360px);
}
.app .df-main {
border-right-color: var(--border);
}
.app .df-body {
padding: 16px 20px 20px;
background: var(--bg-panel);
}
.app .df-controls-row {
margin: 0 0 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px 14px;
}
.app .df-controls-primary {
grid-template-columns: 31px minmax(220px, 340px) minmax(0, auto);
gap: 10px;
}
.app .df-controls-toggles {
gap: 8px;
}
.app .df-refresh-control {
width: 31px;
height: 31px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
box-shadow: none;
flex: 0 0 auto;
}
.app .df-refresh-control:hover:not(:disabled) {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.app .df-controls-row .df-actions {
margin-left: 0;
gap: 8px;
}
.app .df-controls-row .df-actions button {
min-height: 31px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
box-shadow: none;
}
.app .df-controls-row .df-actions button:hover:not(:disabled) {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.app .df-group-toggle {
align-self: flex-start;
margin: 0;
padding: 2px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-subtle);
box-shadow: none;
}
.app .df-group-toggle > span {
padding: 0 8px;
}
.app .df-group-toggle button {
min-height: 26px;
padding: 2px 11px;
border-radius: 8px;
}
.app .df-group-toggle button.active {
background: var(--bg-panel);
box-shadow: none;
}
@media (max-width: 1180px) {
.app .df-controls-row {
grid-template-columns: minmax(0, 1fr);
}
.app .df-controls-primary {
grid-template-columns: 31px minmax(180px, 1fr);
}
}
@media (max-width: 720px) {
.app .df-controls-primary {
grid-template-columns: 31px minmax(0, 1fr);
}
}
.app .df-pagination {
gap: 8px;
padding: 0 0 12px;
color: var(--text-muted);
}
.app .df-pagination-start {
border-bottom: 1px solid var(--border-soft);
}
.app .df-pagination-center {
justify-content: center;
padding: 14px 0 10px;
}
.app .df-pagination select,
.app .df-page-btn {
min-height: 28px;
border-radius: 8px;
background-color: var(--bg-panel);
box-shadow: none;
}
.app .df-select-all {
border-radius: 7px;
}
.app .df-table {
width: 100%;
padding: 0;
border-collapse: collapse;
border-spacing: 0;
table-layout: fixed;
}
.app .df-table thead {
border-bottom: 1px solid var(--border-soft);
}
.app .df-th-check,
.app .df-cell-check {
width: 28px;
padding-left: 8px;
}
.app .df-th-icon,
.app .df-cell-icon {
width: 40px;
}
.app .df-th-kind {
width: 128px;
}
.app .df-th-time {
width: 126px;
}
.app .df-th-menu,
.app .df-cell-menu {
width: 34px;
padding-right: 6px;
}
.app .df-panel:not(.no-preview) .df-th-kind,
.app .df-panel:not(.no-preview) .df-cell-kind,
.app .df-panel:not(.no-preview) .df-th-time,
.app .df-panel:not(.no-preview) .df-cell-time {
display: none;
}
.app .df-panel:not(.no-preview) .df-table {
table-layout: auto;
}
.app .df-panel:not(.no-preview) .df-th-name,
.app .df-panel:not(.no-preview) .df-cell-name {
width: auto;
}
.app .df-panel:not(.no-preview) .df-cell-name {
min-width: 0;
}
.app .df-panel:not(.no-preview) .df-row-name-btn,
.app .df-panel:not(.no-preview) .df-row-name-wrap,
.app .df-panel:not(.no-preview) .df-row-name {
width: 100%;
min-width: 0;
max-width: 100%;
}
.app .df-panel:not(.no-preview) .df-row-name {
display: block;
}
.app .df-th-sortable {
padding: 10px 8px 8px;
font-size: 10px;
}
.app .df-section-row td {
padding: 14px 8px 7px;
border-top: 0;
}
.app .df-section-label {
padding: 0;
font-size: 10px;
}
.app .df-cell-name,
.app .df-cell-kind,
.app .df-cell-time {
padding-top: 8px;
padding-bottom: 8px;
}
.app .df-cell-name {
padding-right: 14px;
}
.app .df-file-row + .df-file-row .df-cell-check,
.app .df-file-row + .df-file-row .df-cell-icon,
.app .df-file-row + .df-file-row .df-cell-name,
.app .df-file-row + .df-file-row .df-cell-kind,
.app .df-file-row + .df-file-row .df-cell-time,
.app .df-file-row + .df-file-row .df-cell-menu {
border-top: 1px solid var(--border-soft);
}
.app .df-file-row:hover {
background: color-mix(in srgb, var(--bg-subtle) 62%, transparent);
}
.app .df-row-icon {
width: 30px;
height: 30px;
border-radius: 8px;
}
.app .df-row-name {
font-size: 13px;
font-weight: 560;
color: var(--text-strong);
}
.app .df-row-name-wrap {
gap: 0;
}
.app .df-row-size,
.app .df-row-sub,
.app .df-row-meta,
.app .df-cell-kind,
.app .df-cell-time,
.app .df-kind-label {
font-size: 12px;
}
.app .df-cell-time,
.app .df-page-info {
color: var(--text-soft);
}
.app .df-drop {
margin: 18px 0 0;
padding: 18px;
border-radius: 10px;
background: var(--bg-panel);
}
.app .df-drop .label {
font-size: 10.5px;
}
.app .df-drop .desc {
font-size: 12px;
max-width: 48ch;
}
/* -- Hand-off button in ChatPane header (paseo-style open-in-editor) -- */
.app .handoff-wrap {
position: relative;
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ import {
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
replaceProjectWorkingDir,
uploadProjectFiles,
} from '../../src/providers/registry';
import {
@@ -70,6 +71,21 @@ vi.mock('../../src/components/EntryView', () => ({
>
Create project
</button>
<button
type="button"
onClick={() =>
onCreateProject({
name: 'Dir project',
skillId: null,
designSystemId: null,
metadata: { kind: 'prototype', userWorkingDir: '/Users/me/external' },
userWorkingDirToken: 'wd-token',
pendingFiles: [new File(['hi'], 'note.txt', { type: 'text/plain' })],
})
}
>
Create project with working dir
</button>
<button
type="button"
onClick={() =>
@@ -162,6 +178,7 @@ vi.mock('../../src/providers/registry', async () => {
fetchDesignTemplates: vi.fn(),
fetchPromptTemplates: vi.fn(),
fetchSkills: vi.fn(),
replaceProjectWorkingDir: vi.fn(),
uploadProjectFiles: vi.fn(),
};
});
@@ -205,6 +222,7 @@ const mockedFetchDesignTemplates = vi.mocked(fetchDesignTemplates);
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
const mockedFetchSkills = vi.mocked(fetchSkills);
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
const mockedReplaceProjectWorkingDir = vi.mocked(replaceProjectWorkingDir);
const mockedCreateProject = vi.mocked(createProject);
const mockedDeleteProject = vi.mocked(deleteProject);
const mockedGetProject = vi.mocked(getProject);
@@ -809,4 +827,66 @@ describe('App project creation routing', () => {
});
expect(screen.queryByTestId('entry-project-project-existing')).toBeNull();
});
it('switches to the picked working dir before uploading staged Home attachments', async () => {
// Regression for the "picked working dir + staged attachment" case:
// replaceProjectWorkingDir flips metadata.baseDir to the external folder,
// so it must run BEFORE uploadProjectFiles — otherwise the staged files
// land in the temporary managed .od/projects/<id> root and vanish once the
// working dir flips. Asserting the call order locks the ordering in.
mockedListProjects.mockResolvedValue([]);
mockedReplaceProjectWorkingDir.mockResolvedValue(undefined as never);
render(<App />);
fireEvent.click(
await screen.findByRole('button', { name: 'Create project with working dir' }),
);
await waitFor(() => {
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledTimes(1);
expect(mockedUploadProjectFiles).toHaveBeenCalledTimes(1);
});
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledWith(
'project-new',
'/Users/me/external',
'wd-token',
);
// Both target the same project id, and the working-dir handoff is ordered
// strictly before the upload so the files land in the final tree.
expect(mockedUploadProjectFiles.mock.calls[0]?.[0]).toBe('project-new');
const replaceOrder = mockedReplaceProjectWorkingDir.mock.invocationCallOrder[0]!;
const uploadOrder = mockedUploadProjectFiles.mock.invocationCallOrder[0]!;
expect(replaceOrder).toBeLessThan(uploadOrder);
});
it('short-circuits the upload + auto-send when the working-dir handoff fails', async () => {
// Regression for the swallowed-failure case: the desktop working-dir token
// has a ~60s TTL, so a slow user (or any rejected POST) makes
// replaceProjectWorkingDir throw AFTER the project already exists. The old
// code only logged a warning and then uploaded the staged attachments into
// the managed root while the user believed their chosen folder was applied.
// The fix surfaces a create-time error toast AND aborts the rest of the
// submit path so the first run cannot proceed on a tree the user did not
// choose.
mockedListProjects.mockResolvedValue([]);
mockedReplaceProjectWorkingDir.mockRejectedValue(
new Error('working-dir token expired'),
);
render(<App />);
fireEvent.click(
await screen.findByRole('button', { name: 'Create project with working dir' }),
);
await waitFor(() => {
expect(screen.getByText(/Couldn't apply the chosen folder/i)).toBeTruthy();
});
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledTimes(1);
// The handoff failed, so the staged attachments must NOT be uploaded into
// the managed `.od/projects/<id>` root the user did not pick.
expect(mockedUploadProjectFiles).not.toHaveBeenCalled();
});
});
@@ -90,14 +90,14 @@ describe('DesignFilesPanel long filename truncation (#3260)', () => {
expect(nameSpan?.getAttribute('title')).toBe(LONG_NAME);
});
it('keeps the truncate-friendly DOM structure (.df-cell-name > .df-row-name-btn > .df-row-name-wrap > .df-row-name)', () => {
it('keeps the truncate-friendly DOM structure (.df-row-name-wrap > .df-row-name-btn > .df-row-name-wrap > .df-row-name)', () => {
const { container } = renderPanel([file({ name: LONG_NAME })]);
// The CSS fix relies on this nesting: `td.df-cell-name` constrains
// its width, the wrap is min-width:0 / max-width:100%, and
// `.df-row-name` carries `text-overflow: ellipsis`. If the JSX
// shape ever changes the CSS regression risk returns silently —
// this asserts the chain stays intact.
const cell = container.querySelector('td.df-cell-name');
// The CSS fix relies on this nesting: the outer `.df-row-name-wrap`
// cell constrains its width, the inner wrap is min-width:0 /
// max-width:100%, and `.df-row-name` carries `text-overflow: ellipsis`.
// If the JSX shape ever changes the CSS regression risk returns
// silently — this asserts the chain stays intact.
const cell = container.querySelector('div.df-row-name-wrap');
expect(cell).toBeTruthy();
const btn = cell!.querySelector('button.df-row-name-btn');
expect(btn).toBeTruthy();
@@ -1,9 +1,8 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
@@ -60,404 +59,143 @@ function renderPanel(
) {
const onOpenFile = vi.fn();
const onDeleteFiles = vi.fn();
const onUploadFiles = vi.fn();
const onRenameFile = vi.fn();
const onClearUploadError = vi.fn();
const result = render(
<DesignFilesPanel
projectId="test-project"
files={files}
folders={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={onOpenFile}
onOpenLiveArtifact={vi.fn()}
onRenameFile={onRenameFile}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={onDeleteFiles}
onUpload={vi.fn()}
onUploadFiles={onUploadFiles}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
onClearUploadError={onClearUploadError}
{...overrides}
/>,
);
return {
...result,
onDeleteFiles,
onOpenFile,
onRenameFile,
onUploadFiles,
onClearUploadError,
};
return { ...result, onDeleteFiles, onOpenFile, onClearUploadError };
}
function getPageInfo(container: HTMLElement): string {
const el = container.querySelector('.df-page-info');
return el?.textContent?.trim() ?? '';
function sectionLabels(): string[] {
return Array.from(document.querySelectorAll<HTMLElement>('.df-section-label')).map(
(el) => el.textContent ?? '',
);
}
/** page-btn order: bottom-Prev=0, bottom-Next=1 */
function getPageBtns(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLButtonElement>('.df-page-btn'));
}
function getSelects(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
}
describe('DesignFilesPanel grouping', () => {
beforeEach(() => {
lsStore.clear();
});
describe('DesignFilesPanel sections', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('does not show grouping controls when only live artifacts are available', () => {
render(
<DesignFilesPanel
projectId="project-1"
files={[]}
liveArtifacts={[
{
kind: 'live-artifact',
artifactId: 'artifact-1',
tabId: 'live:artifact-1',
projectId: 'project-1',
title: 'Live Preview',
slug: 'live-preview',
status: 'active',
refreshStatus: 'idle',
pinned: false,
preview: { type: 'html', entry: 'index.html' },
hasDocument: true,
updatedAt: '2026-05-09T12:00:00.000Z',
},
]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
it('does not show grouping, sort, filter, or pagination chrome', () => {
renderPanel(generateFiles(60));
expect(screen.queryByRole('group', { name: 'Group by' })).toBeNull();
expect(screen.getByTestId('design-file-row-live:artifact-1')).toBeTruthy();
expect(document.querySelector('.df-table')).toBeNull();
expect(document.querySelector('.df-th-sortable')).toBeNull();
expect(document.querySelector('.df-kind-filter')).toBeNull();
expect(document.querySelector('.df-pagination')).toBeNull();
expect(document.querySelector('.df-page-btn')).toBeNull();
});
it('groups files by kind when kind grouping is selected', () => {
it('renders a single-line toolbar with file actions and no up/refresh buttons', () => {
renderPanel([file({ name: 'page.html', kind: 'html' })]);
expect(document.querySelector('.df-topbar')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Up' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull();
expect(document.querySelector('.df-up-btn')).toBeNull();
expect(document.querySelector('.df-refresh-control')).toBeNull();
expect(screen.getByTestId('design-files-upload-trigger')).toBeTruthy();
});
it('groups files into semantic sections by category', () => {
renderPanel([
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
]);
const sectionLabels = Array.from(
document.querySelectorAll<HTMLElement>('.df-section-label'),
).map((el) => el.textContent ?? '');
expect(sectionLabels.some((text) => text.includes('HTML page'))).toBe(true);
expect(sectionLabels.some((text) => text.includes('Image'))).toBe(true);
const labels = sectionLabels();
expect(labels.some((text) => text.includes('Pages'))).toBe(true);
expect(labels.some((text) => text.includes('Images'))).toBe(true);
expect(screen.getByTestId('design-file-row-page.html')).toBeTruthy();
expect(screen.getByTestId('design-file-row-chart.png')).toBeTruthy();
expect(screen.queryByText('Today')).toBeNull();
});
it('keeps kind grouping selected by default', () => {
it('splits stylesheets into their own section with a Stylesheet subtitle', () => {
renderPanel([
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
file({ name: 'styles.css', kind: 'code', mime: 'text/css' }),
file({ name: 'app.ts', kind: 'code', mime: 'text/typescript' }),
]);
const groupControls = screen.getByRole('group', { name: 'Group by' });
const kindGroupButton = within(groupControls).getByRole('button', { name: 'Kind' });
expect(kindGroupButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByText('Name')).toBeTruthy();
expect(document.querySelector('.df-th-kind')?.textContent).toContain('Kind');
expect(screen.queryByText('Today')).toBeNull();
const labels = sectionLabels();
expect(labels.some((text) => text.includes('Stylesheets'))).toBe(true);
expect(labels.some((text) => text.includes('Scripts'))).toBe(true);
const cssRow = screen.getByTestId('design-file-row-styles.css');
expect(cssRow.querySelector('.df-row-sub')?.textContent).toBe('Stylesheet');
const tsRow = screen.getByTestId('design-file-row-app.ts');
expect(tsRow.querySelector('.df-row-sub')?.textContent).toBe('Script');
});
it('can group files by modified date and collapse a date group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
it('shows the type as the row subtitle instead of file size', () => {
renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]);
renderPanel([
file({ name: 'today.html', mtime: new Date(2026, 4, 9, 11).getTime() }),
file({ name: 'yesterday.html', mtime: new Date(2026, 4, 8, 12).getTime() }),
]);
expect(screen.queryByText('Today')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Today')).toBeTruthy();
expect(screen.getByText('Yesterday')).toBeTruthy();
expect(screen.getByTestId('design-file-row-today.html')).toBeTruthy();
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /Collapse Today/i }));
expect(screen.queryByTestId('design-file-row-today.html')).toBeNull();
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
const row = screen.getByTestId('design-file-row-chart.png');
expect(row.querySelector('.df-row-sub')?.textContent).toBe('Image');
expect(row.textContent).not.toContain('KB');
});
it('keeps files from seven calendar days ago in the previous 7 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
it('renders a static useful-info footer', () => {
renderPanel([file({ name: 'page.html', kind: 'html' })]);
renderPanel([file({ name: 'week-old.html', mtime: new Date(2026, 4, 2, 12).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 7 days')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-week-old.html')).toBeTruthy();
});
it('keeps files at the seven calendar day boundary in the previous 7 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({ name: 'week-boundary.html', mtime: new Date(2026, 4, 2, 0, 0, 0, 0).getTime() }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 7 days')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-week-boundary.html')).toBeTruthy();
});
it('keeps files from thirty calendar days ago in the previous 30 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({ name: 'month-old.html', mtime: new Date(2026, 3, 9, 12).getTime() }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 30 days')).toBeTruthy();
expect(screen.queryByText('Older')).toBeNull();
expect(screen.getByTestId('design-file-row-month-old.html')).toBeTruthy();
});
it('keeps files at the thirty calendar day boundary in the previous 30 days group', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([
file({
name: 'month-boundary.html',
mtime: new Date(2026, 3, 9, 0, 0, 0, 0).getTime(),
}),
]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Previous 30 days')).toBeTruthy();
expect(screen.queryByText('Older')).toBeNull();
expect(screen.getByTestId('design-file-row-month-boundary.html')).toBeTruthy();
});
it('groups files older than thirty calendar days into older', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel([file({ name: 'archive.html', mtime: new Date(2026, 3, 8, 12).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Older')).toBeTruthy();
expect(screen.queryByText('Previous 30 days')).toBeNull();
expect(screen.getByTestId('design-file-row-archive.html')).toBeTruthy();
});
it('groups only the current page so large file lists stay paginated', () => {
const now = new Date(2026, 4, 9, 12).getTime();
vi.useFakeTimers();
vi.setSystemTime(now);
renderPanel(
Array.from({ length: 31 }, (_, i) =>
file({ name: `today-${String(i + 1).padStart(2, '0')}.html`, mtime: now - i }),
),
);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByTestId('design-file-row-today-01.html')).toBeTruthy();
expect(screen.queryByTestId('design-file-row-today-31.html')).toBeNull();
expect(getPageInfo(document.body)).toContain('1–30 of 31');
});
it('updates modified date groups when the local day changes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 9, 23, 59, 50));
renderPanel([file({ name: 'late-edit.html', mtime: new Date(2026, 4, 9, 23, 59).getTime() })]);
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
expect(screen.getByText('Today')).toBeTruthy();
expect(screen.queryByText('Yesterday')).toBeNull();
act(() => {
vi.advanceTimersByTime(10_001);
});
expect(screen.getByText('Yesterday')).toBeTruthy();
expect(screen.queryByText('Today')).toBeNull();
expect(screen.getByTestId('design-file-row-late-edit.html')).toBeTruthy();
expect(document.querySelector('.df-useful-info-label')?.textContent).toBe('Useful info');
expect(document.querySelector('.df-useful-info-tip')?.textContent).toContain('Double-click');
});
});
describe('DesignFilesPanel large-list regression', () => {
beforeEach(() => {
lsStore.clear();
});
afterEach(() => {
cleanup();
});
it('renders only the default page size (30) rows with 500 files', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
});
it('shows all 500 rows when page size is set to All', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
const selects = getSelects(container);
fireEvent.change(selects[0]!, { target: { value: 'all' } });
describe('DesignFilesPanel large list', () => {
afterEach(() => cleanup());
it('renders all rows at once (no pagination)', () => {
const { container } = renderPanel(generateFiles(500));
expect(container.querySelectorAll('.df-file-row').length).toBe(500);
});
it('shows 60 rows when page size is changed to 60', () => {
it('renders 500 files within a reasonable time', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
const selects = getSelects(container);
fireEvent.change(selects[0]!, { target: { value: '60' } });
expect(container.querySelectorAll('.df-file-row').length).toBe(60);
const start = performance.now();
renderPanel(files);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(2000);
});
});
it('navigates pages with Next button and updates row content', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
describe('DesignFilesPanel selection', () => {
afterEach(() => cleanup());
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-1');
it('shows the batch bar and passes every selected file to batch delete', () => {
const files = generateFiles(3);
const { container, onDeleteFiles } = renderPanel(files);
const rows = Array.from(container.querySelectorAll('.df-file-row'));
const btns = getPageBtns(container);
fireEvent.click(btns[1]!);
const firstName = rows[0]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
const secondName = rows[1]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
fireEvent.click(rows[0]!.querySelector('.df-row-check')!);
fireEvent.click(rows[1]!.querySelector('.df-row-check')!);
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-31');
});
expect(container.querySelector('[data-testid="design-files-batch-bar"]')).toBeTruthy();
it('shows disabled Previous on first page and Next on last page', () => {
const files = generateFiles(45);
const { container } = renderPanel(files);
const btns = getPageBtns(container);
expect(btns[0]!.disabled).toBe(true);
expect(btns[1]!.disabled).toBe(false);
fireEvent.click(btns[1]!);
const btns2 = getPageBtns(container);
expect(btns2[0]!.disabled).toBe(false);
fireEvent.click(getPageBtns(container)[1]!);
fireEvent.click(getPageBtns(container)[1]!);
expect(getPageBtns(container)[1]!.disabled).toBe(true);
});
it('jumps to a specific page via page dropdown at bottom', () => {
const files = generateFiles(200);
const { container } = renderPanel(files);
const selects = getSelects(container);
fireEvent.change(selects[1]!, { target: { value: '3' } });
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-91');
});
it('updates page info text when navigating', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
expect(getPageInfo(container)).toContain('1–30 of 500');
const btns = getPageBtns(container);
fireEvent.click(btns[1]!);
expect(getPageInfo(container)).toContain('31–60 of 500');
});
it('keeps the bulk toolbar focused on the all-files action instead of duplicating page select', () => {
const { container } = renderPanel(generateFiles(20));
const toolbar = container.querySelector('.df-select-bar');
expect(toolbar?.textContent).toContain('Select everything');
expect(toolbar?.textContent).not.toContain('Select all on page');
});
it('hides redundant pagination controls for a single small page', () => {
const { container } = renderPanel(generateFiles(3));
expect(container.querySelector('.df-pagination')).toBeNull();
expect(container.querySelector('.df-page-btn')).toBeNull();
expect(container.querySelector('.df-select-bar')).toBeNull();
});
it('uses non-control table cells as file row click targets', () => {
const files = generateFiles(1);
const { container, onOpenFile } = renderPanel(files);
const row = container.querySelector('.df-file-row')!;
fireEvent.click(row.querySelector('.df-cell-icon')!);
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
'file-1.html',
);
fireEvent.click(row.querySelector('.df-cell-kind')!);
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
'file-1.html',
);
fireEvent.click(row.querySelector('.df-cell-name')!);
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
'file-1.html',
);
fireEvent.doubleClick(row.querySelector('.df-cell-name')!);
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
onOpenFile.mockClear();
fireEvent.doubleClick(row.querySelector('.df-cell-time')!);
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
});
it('does not preview or open files from row controls', () => {
@@ -474,6 +212,36 @@ describe('DesignFilesPanel large-list regression', () => {
expect(onOpenFile).not.toHaveBeenCalled();
});
it('uses non-control row targets to preview and open', () => {
const files = generateFiles(1);
const { container, onOpenFile } = renderPanel(files);
const row = container.querySelector('.df-file-row')!;
fireEvent.click(row.querySelector('.df-row-icon')!);
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
'file-1.html',
);
fireEvent.doubleClick(row.querySelector('.df-row-name-btn')!);
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
onOpenFile.mockClear();
fireEvent.doubleClick(row.querySelector('.df-row-time')!);
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
});
});
describe('DesignFilesPanel preview', () => {
afterEach(() => cleanup());
it('shows the file extension in the preview stats', () => {
const { container } = renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]);
fireEvent.click(container.querySelector('.df-file-row .df-row-icon')!);
const stats = container.querySelector('.df-preview-stats')?.textContent ?? '';
expect(stats).toContain('PNG');
});
it('renders sketch files with the static sketch preview instead of a broken image', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
version: 1,
@@ -510,54 +278,6 @@ describe('DesignFilesPanel large-list regression', () => {
expect(container.querySelector('.df-preview-thumb img')).toBeNull();
expect(fetchMock).toHaveBeenCalledWith('/api/projects/test-project/raw/board.sketch.json', { cache: 'no-store' });
});
it('passes every selected file to batch delete', () => {
const files = generateFiles(3);
const { container, onDeleteFiles } = renderPanel(files);
const rows = Array.from(container.querySelectorAll('.df-file-row'));
const firstName = rows[0]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
const secondName = rows[1]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
fireEvent.click(rows[0]!.querySelector('.df-row-check')!);
fireEvent.click(rows[1]!.querySelector('.df-row-check')!);
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
});
it('drops hidden selections before batch delete when a kind filter narrows the file list', () => {
const files = [
file({ name: 'landing.html', kind: 'html', mime: 'text/html' }),
file({ name: 'hero.png', kind: 'image', mime: 'image/png' }),
];
const { onDeleteFiles } = renderPanel(files);
fireEvent.click(screen.getByTestId('design-file-row-landing.html').querySelector('.df-row-check')!);
fireEvent.click(screen.getByTestId('design-file-row-hero.png').querySelector('.df-row-check')!);
fireEvent.click(screen.getByRole('button', { name: 'Filter by kind' }));
const filterDialog = screen.getByRole('dialog', { name: 'Filter by kind' });
const imageFilterLabel = within(filterDialog).getByText('Image').closest('label');
if (!imageFilterLabel) throw new Error('Missing image filter label');
fireEvent.click(imageFilterLabel.querySelector('input')!);
expect(screen.queryByTestId('design-file-row-landing.html')).toBeNull();
expect(screen.getByTestId('design-file-row-hero.png')).toBeTruthy();
fireEvent.click(screen.getByTestId('design-files-batch-delete'));
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
expect(onDeleteFiles).toHaveBeenCalledWith(['hero.png']);
});
it('renders 500 files within a reasonable time', () => {
const files = generateFiles(500);
const start = performance.now();
renderPanel(files);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(2000);
});
});
describe('DesignFilesPanel directory navigation', () => {
@@ -577,6 +297,15 @@ describe('DesignFilesPanel directory navigation', () => {
expect(dirRows[0]!.textContent).toContain('2');
});
it('pins folders into a Folders section', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
expect(sectionLabels().some((text) => text.includes('Folders'))).toBe(true);
});
it('clicking a folder row navigates into it and shows only basenames and nested dirs', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
@@ -597,6 +326,26 @@ describe('DesignFilesPanel directory navigation', () => {
expect(dirRows[0]!.textContent).toContain('icons');
});
it('always renders the root breadcrumb on the default-root view', () => {
// Regression: managed-storage projects have currentDir==='' and no
// rootDirName, which previously collapsed the whole breadcrumb nav to null
// and left the toolbar blank on the left for the most common path. The root
// crumb must always render, falling back to the t('designFiles.crumbs')
// label when no rootDirName exists.
renderPanel([file({ name: 'top.html', kind: 'html' })]);
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('project');
});
it('shows rootDirName as the root breadcrumb when one is provided', () => {
renderPanel([file({ name: 'top.html', kind: 'html' })], {
rootDirName: 'my-folder',
});
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('my-folder');
});
it('clicking the root breadcrumb navigates back to root', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
@@ -608,12 +357,24 @@ describe('DesignFilesPanel directory navigation', () => {
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
expect(document.querySelector('.df-breadcrumb-current')?.textContent).not.toBe('assets');
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
expect(document.querySelectorAll('.df-dir-row').length).toBe(1);
});
it('clears selection and resets page when navigating into or out of a directory', () => {
it('navigates up one level via the parent breadcrumb', () => {
renderPanel([file({ name: 'assets/icons/star.svg', kind: 'image' })]);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('icons');
const crumbs = Array.from(document.querySelectorAll('.df-breadcrumb-btn'));
fireEvent.click(crumbs[crumbs.length - 1]!);
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
});
it('clears selection when navigating into or out of a directory', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
@@ -663,183 +424,7 @@ describe('DesignFilesPanel directory navigation', () => {
rerender(makePanel([file({ name: 'top.html', kind: 'html' })]));
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
expect(document.querySelector('.df-breadcrumb-current')?.textContent).not.toBe('assets');
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
});
it('returns to root after batch deleting the last files in the current subdirectory', async () => {
const deleteSpy = vi.fn();
function Harness() {
const [files, setFiles] = useState<ProjectFile[]>([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'assets/hero.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
return (
<DesignFilesPanel
projectId="test-project"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={async (names) => {
deleteSpy(names);
setFiles((current) => current.filter((candidate) => !names.includes(candidate.name)));
}}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>
);
}
render(<Harness />);
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
fireEvent.click(screen.getByTestId('design-file-row-assets/logo.png').querySelector('.df-row-check')!);
fireEvent.click(screen.getByTestId('design-file-row-assets/hero.png').querySelector('.df-row-check')!);
fireEvent.click(screen.getByTestId('design-files-batch-delete'));
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalledWith(['assets/logo.png', 'assets/hero.png']);
});
await waitFor(() => {
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
});
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
});
it('does not show the select-all header as checked when the page contains only directory rows', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
]);
const headerCheck = document.querySelector('.df-th-check .df-row-check');
expect(headerCheck?.textContent).toBe('☐');
});
it('shows persisted empty folders', () => {
renderPanel([], {
folders: [{ name: 'assets', path: 'assets', type: 'dir', size: 0, mtime: Date.now() }],
});
const dirRows = document.querySelectorAll('.df-dir-row');
expect(dirRows.length).toBe(1);
expect(dirRows[0]!.textContent).toContain('assets');
expect(dirRows[0]!.textContent).toContain('0');
expect(screen.queryByTestId('design-files-empty')).toBeNull();
});
it('creates a folder in the current directory', async () => {
const promptSpy = vi.spyOn(window, 'prompt').mockImplementation(() => {
throw new Error('prompt should not be used for folder creation');
});
const onCreateFolder = vi.fn(async () => ({
name: 'assets/untitled-folder',
path: 'assets/untitled-folder',
type: 'dir' as const,
size: 0 as const,
mtime: Date.now(),
}));
renderPanel([file({ name: 'assets/logo.png', kind: 'image' })], { onCreateFolder });
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
fireEvent.click(screen.getByRole('button', { name: 'Folder' }));
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(onCreateFolder).toHaveBeenCalledWith('assets/untitled-folder');
});
expect(promptSpy).not.toHaveBeenCalled();
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('untitled-folder');
promptSpy.mockRestore();
});
it('searches nested file paths across the project', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
fireEvent.click(screen.getByRole('button', { name: 'Search files…' }));
fireEvent.change(screen.getByLabelText('Search files…'), { target: { value: 'logo' } });
expect(screen.getByTestId('design-file-row-assets/logo.png')).toBeTruthy();
expect(screen.queryByTestId('design-file-row-top.html')).toBeNull();
expect(screen.getByText('assets/logo.png')).toBeTruthy();
});
it('keeps Design Files search compact until focused and clearable', () => {
renderPanel([
file({ name: 'assets/logo.png', kind: 'image' }),
file({ name: 'top.html', kind: 'html' }),
]);
expect(document.querySelector('.df-search-control')?.className).not.toContain('is-expanded');
fireEvent.click(screen.getByRole('button', { name: 'Search files…' }));
const input = screen.getByLabelText('Search files…');
fireEvent.change(input, { target: { value: 'logo' } });
expect(document.querySelector('.df-search-control')?.className).toContain('is-expanded');
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
fireEvent.blur(input, { relatedTarget: document.body });
expect(document.querySelector('.df-search-control')?.className).not.toContain('is-expanded');
});
it('accepts dropped files anywhere on the design files panel', async () => {
const upload = new File(['hello'], 'hello.txt', { type: 'text/plain' });
const { container, onUploadFiles } = renderPanel([]);
fireEvent.drop(container.querySelector('.df-panel')!, {
dataTransfer: {
types: ['Files'],
files: [upload],
},
});
await waitFor(() => {
expect(onUploadFiles).toHaveBeenCalledWith([upload]);
});
});
it('moves dragged files into a folder row', async () => {
const data = new Map<string, string>();
const dataTransfer = {
types: [] as string[],
effectAllowed: '',
dropEffect: '',
setData(type: string, value: string) {
if (!this.types.includes(type)) this.types.push(type);
data.set(type, value);
},
getData(type: string) {
return data.get(type) ?? '';
},
};
const onRenameFile = vi.fn(async (_from: string, to: string) => file({ name: to, kind: 'image' }));
renderPanel([
file({ name: 'assets/.keep', kind: 'text' }),
file({ name: 'logo.png', kind: 'image' }),
], { onRenameFile });
fireEvent.dragStart(screen.getByTestId('design-file-row-logo.png'), { dataTransfer });
fireEvent.dragOver(document.querySelector('.df-dir-row')!, { dataTransfer });
fireEvent.drop(document.querySelector('.df-dir-row')!, { dataTransfer });
await waitFor(() => {
expect(onRenameFile).toHaveBeenCalledWith('logo.png', 'assets/logo.png');
});
});
});
@@ -1,191 +0,0 @@
// @vitest-environment jsdom
//
// Red spec for bug #3a: view state (pageSize, sortKey, sortDir, kindFilter)
// resets on navigation because the component remounts via key={projectId}.
// These tests must go RED on origin/main and GREEN after the fix.
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import type { ProjectFile, ProjectFileKind } from '../../src/types';
// Minimal localStorage stub mirroring the pattern in state/config.test.ts
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
});
function file(name: string, kind: ProjectFileKind = 'html', mtime = Date.now()): ProjectFile {
return { path: name, name, type: 'file', size: 1024, mtime, kind, mime: 'text/html' };
}
function generateFiles(count: number): ProjectFile[] {
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
return Array.from({ length: count }, (_, i) => {
const kind = kinds[i % kinds.length]!;
return file(`file-${i + 1}.html`, kind, Date.now() - i * 60_000);
});
}
function renderPanel(
files: ProjectFile[],
projectId = 'proj-a',
) {
return render(
<DesignFilesPanel
projectId={projectId}
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
/>,
);
}
function getPerPageSelect(container: HTMLElement): HTMLSelectElement {
// The per-page select is the first select in the panel
return container.querySelector<HTMLSelectElement>('.df-pagination-start select')!;
}
function getSortBtn(container: HTMLElement, label: string): HTMLElement {
return Array.from(container.querySelectorAll<HTMLElement>('.df-th-btn')).find(
(el) => el.textContent?.trim().startsWith(label),
)!;
}
describe('DesignFilesPanel view-state persistence', () => {
beforeEach(() => {
store.clear();
vi.mocked(localStorage.getItem).mockClear();
vi.mocked(localStorage.setItem).mockClear();
});
afterEach(() => {
cleanup();
});
it('restores pageSize from localStorage after remount', () => {
const files = generateFiles(500);
// First mount: change page size to 60
const first = renderPanel(files);
const sel = getPerPageSelect(first.container);
fireEvent.change(sel, { target: { value: '60' } });
first.unmount();
// Second mount simulates navigation away and back (key={projectId} causes remount)
const second = renderPanel(files);
const restoredSel = getPerPageSelect(second.container);
expect(restoredSel.value).toBe('60');
});
it('restores sort key from localStorage after remount', () => {
const files = generateFiles(50);
// First mount: click "Name" header to sort by name
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();
// Second mount: "Name" column should show the sort arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↑');
});
it('restores sort direction from localStorage after remount', () => {
const files = generateFiles(50);
// First mount: click "Name" twice to get desc
const first = renderPanel(files);
fireEvent.click(getSortBtn(first.container, 'Name'));
fireEvent.click(getSortBtn(first.container, 'Name'));
first.unmount();
// Second mount: Name column should show desc arrow
const second = renderPanel(files);
const nameBtn = getSortBtn(second.container, 'Name');
expect(nameBtn.textContent).toContain('↓');
});
it('restores kindFilter from localStorage after remount', () => {
// Files with mixed kinds so the filter button appears
const files = [
file('a.html', 'html'),
file('b.png', 'image'),
file('c.txt', 'text'),
];
// First mount: open the filter popover and check 'HTML page'
const first = renderPanel(files);
const filterTrigger = first.container.querySelector<HTMLElement>('.df-kind-filter-trigger');
expect(filterTrigger).not.toBeNull();
fireEvent.click(filterTrigger!);
const checkboxes = first.container.querySelectorAll<HTMLInputElement>(
'.df-kind-filter-list input[type="checkbox"]',
);
// Check the first checkbox (HTML)
if (checkboxes[0]) fireEvent.click(checkboxes[0]);
first.unmount();
// Second mount: filter button should show active state (a kind is selected)
const second = renderPanel(files);
const trigger = second.container.querySelector('.df-kind-filter-trigger');
expect(trigger?.classList.contains('active')).toBe(true);
});
it('does not bleed pageSize from one project into another', () => {
const files = generateFiles(500);
// Project A: set page size 60
const first = renderPanel(files, 'proj-a');
fireEvent.change(getPerPageSelect(first.container), { target: { value: '60' } });
first.unmount();
// Project B: should still have the default (30), not project A's setting
const second = renderPanel(files, 'proj-b');
expect(getPerPageSelect(second.container).value).toBe('30');
});
it('writes view state to localStorage on pageSize change', () => {
const files = generateFiles(500);
const { container } = renderPanel(files);
fireEvent.change(getPerPageSelect(container), { target: { value: '45' } });
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith(
expect.stringMatching(/od:design-files:view-state/),
expect.any(String),
);
});
it('falls back to default pageSize when stored value is not a supported option', () => {
const files = generateFiles(500);
// Seed localStorage with an unsupported value (fractional, out-of-set integer)
for (const bad of [0.5, 17, 999, -1, 0]) {
vi.mocked(localStorage.getItem).mockReturnValueOnce(JSON.stringify({ pageSize: bad }));
const { container } = renderPanel(files);
expect(getPerPageSelect(container).value).toBe('30');
cleanup();
}
});
});
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HomeView } from '../../src/components/HomeView';
import { isOpenDesignHostAvailable, pickHostWorkingDir } from '@open-design/host';
import { openFolderDialog } from '../../src/providers/registry';
vi.mock('@open-design/host', async () => {
const actual = await vi.importActual<typeof import('@open-design/host')>('@open-design/host');
return {
...actual,
isOpenDesignHostAvailable: vi.fn(),
pickHostWorkingDir: vi.fn(),
};
});
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
openFolderDialog: vi.fn(),
fetchProjectFiles: vi.fn().mockResolvedValue([]),
};
});
const mockedIsHostAvailable = vi.mocked(isOpenDesignHostAvailable);
const mockedPickHostWorkingDir = vi.mocked(pickHostWorkingDir);
const mockedOpenFolderDialog = vi.mocked(openFolderDialog);
function renderHome() {
return render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
}
describe('HomeView working-dir picker host fallback', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedOpenFolderDialog.mockResolvedValue(null);
});
afterEach(() => {
cleanup();
});
// Regression for the "host unavailable + desktop gate active" case. When the
// desktop host is present but cannot mint a working-dir token (mixed-version
// upgrade where the preload lacks project.pickWorkingDir, or a host error),
// we must NOT fall back to the browser folder dialog: that path yields a raw
// path with no host token, so the later working-dir POST would be rejected by
// the desktop auth gate and surface as a confusing late create-time failure.
it('surfaces the host error instead of falling back to the browser dialog when the host pick fails', async () => {
mockedIsHostAvailable.mockReturnValue(true);
mockedPickHostWorkingDir.mockResolvedValue({
ok: false,
reason: 'host build does not support pickWorkingDir',
});
renderHome();
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
await waitFor(() => {
expect(screen.getByText(/Couldn't open the folder picker/i)).toBeTruthy();
});
expect(mockedOpenFolderDialog).not.toHaveBeenCalled();
});
// The pure web path (no desktop host) has no token gate, so the raw browser
// folder path is the expected input and the dialog fallback is correct.
it('uses the browser folder dialog on the pure web path', async () => {
mockedIsHostAvailable.mockReturnValue(false);
mockedOpenFolderDialog.mockResolvedValue('/Users/me/web-folder');
renderHome();
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
await waitFor(() => {
expect(mockedOpenFolderDialog).toHaveBeenCalledTimes(1);
});
expect(mockedPickHostWorkingDir).not.toHaveBeenCalled();
});
// An explicit cancel of the host picker must not pop a second dialog.
it('does not fall back to the browser dialog when the user cancels the host picker', async () => {
mockedIsHostAvailable.mockReturnValue(true);
mockedPickHostWorkingDir.mockResolvedValue({ ok: false, canceled: true });
renderHome();
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
await waitFor(() => {
expect(mockedPickHostWorkingDir).toHaveBeenCalledTimes(1);
});
expect(mockedOpenFolderDialog).not.toHaveBeenCalled();
});
});
@@ -33,6 +33,18 @@ describe('WorkingDirPill', () => {
expect(screen.getByTestId('working-dir-pill-menu')).toBeTruthy();
});
it('renders only the folder name for Windows backslash paths', () => {
render(
<WorkingDirPill
projectId="project-1"
resolvedDir="C:\\work\\repo"
/>,
);
expect(screen.getByTestId('working-dir-pill-trigger').textContent).toContain('repo');
expect(screen.getByTestId('working-dir-pill-trigger').textContent).not.toContain('C:\\work');
});
it('opens the project directory through the desktop host bridge', async () => {
const openPath = vi.fn(async () => ({ ok: true as const }));
restoreHost = installMockOpenDesignHost({
+61
View File
@@ -63,6 +63,24 @@ export type OpenDesignHostProjectReplaceWorkingDirResult =
}
| OpenDesignHostFailure;
export type OpenDesignHostPickWorkingDirSuccess = {
baseDir: string;
ok: true;
// Single-use HMAC token (minted by the host main process for `baseDir`)
// that the renderer threads into POST /api/projects/:id/working-dir once
// the project exists. Lets the Home flow pick a folder before the project
// is created without exposing the daemon's desktop-auth gate.
token: string;
};
export type OpenDesignHostPickWorkingDirResult =
| OpenDesignHostPickWorkingDirSuccess
| {
canceled: true;
ok: false;
}
| OpenDesignHostFailure;
export type OpenDesignHostPdfPrintOptions = {
deck?: boolean;
};
@@ -229,6 +247,9 @@ export type OpenDesignHostBridge = {
project: {
pickAndImport(init?: OpenDesignHostProjectImportInit): Promise<OpenDesignHostProjectImportResult>;
pickAndReplaceWorkingDir(projectId: string): Promise<OpenDesignHostProjectReplaceWorkingDirResult>;
// Optional so older host builds still satisfy the bridge shape; callers
// must feature-detect before invoking.
pickWorkingDir?(): Promise<OpenDesignHostPickWorkingDirResult>;
};
shell: {
openExternal(url: string): Promise<OpenDesignHostActionResult>;
@@ -381,6 +402,27 @@ export function normalizeOpenDesignHostProjectReplaceWorkingDirResult(
return { baseDir, entryFile, ok: true };
}
export function normalizeOpenDesignHostPickWorkingDirResult(
input: unknown,
): OpenDesignHostPickWorkingDirResult {
if (!isRecord(input)) {
return failure("desktop working-dir pick returned an invalid response", input);
}
if (input.ok !== true) {
if (input.canceled === true) return { canceled: true, ok: false };
const reason = typeof input.reason === "string" && input.reason.length > 0
? input.reason
: "unknown failure";
return failure(reason, input.details);
}
const baseDir = typeof input.baseDir === "string" ? input.baseDir : null;
const token = typeof input.token === "string" ? input.token : null;
if (baseDir == null || token == null) {
return failure("desktop working-dir pick did not include baseDir and token", input);
}
return { baseDir, ok: true, token };
}
function candidateFromScope(scope: OpenDesignHostGlobalScope): unknown {
if (OPEN_DESIGN_HOST_GLOBAL in scope) return scope[OPEN_DESIGN_HOST_GLOBAL];
const windowValue = scope.window;
@@ -479,6 +521,25 @@ export async function pickAndReplaceHostProjectWorkingDir(
}
}
// Picks a folder via the host's native dialog and returns the chosen path
// plus a single-use token, WITHOUT touching any project. The Home flow uses
// this to let the user choose a working directory before the project exists;
// the token is later spent on POST /api/projects/:id/working-dir.
export async function pickHostWorkingDir(
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostPickWorkingDirResult> {
const host = getOpenDesignHost(scope);
if (host == null) return unavailable("Open Design host is not available");
if (typeof host.project.pickWorkingDir !== "function") {
return unavailable("host build does not support pickWorkingDir");
}
try {
return await host.project.pickWorkingDir();
} catch (error) {
return unavailable(error instanceof Error ? error.message : String(error));
}
}
export async function printHostPdf(
html: string,
nonce?: string,