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.
This commit is contained in:
qiongyu1999 2026-05-31 01:37:07 +08:00
parent 7f72be13cf
commit f71cdd3822
33 changed files with 267 additions and 90 deletions

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 {',

View file

@ -3133,6 +3133,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")'],

View file

@ -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();');
});

View file

@ -6,6 +6,7 @@ import type {
OpenDesignHostFailure,
OpenDesignHostProjectImportResult,
OpenDesignHostProjectReplaceWorkingDirResult,
OpenDesignHostPickWorkingDirResult,
OpenDesignHostUpdaterActionOptions,
OpenDesignHostUpdaterStatusListener,
OpenDesignHostUpdaterStatusSnapshot,
@ -91,6 +92,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) {
@ -156,6 +178,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 = {

View file

@ -1045,6 +1045,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");
for (const channel of UPDATER_IPC_CHANNELS) {
@ -1098,7 +1099,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 };
}
@ -1141,7 +1142,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 };
}
@ -1158,6 +1159,27 @@ 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 };
}
const baseDir = result.filePaths[0].trim();
if (baseDir.length === 0) {
return { ok: false, reason: "picker returned an empty path" };
}
const token = mintImportToken(options.desktopAuthSecret, baseDir);
return { baseDir, ok: true, token };
});
// 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

View file

@ -48,6 +48,7 @@ import {
fetchPromptTemplates,
fetchSkills,
uploadProjectFiles,
replaceProjectWorkingDir,
} from './providers/registry';
import { RUNS_CHANGED_EVENT, listProjectRuns } from './providers/daemon';
import { navigate, useRoute } from './router';
@ -832,6 +833,7 @@ function AppInner() {
autoSendFirstMessage?: boolean;
requestId?: string;
pendingFiles?: File[];
userWorkingDirToken?: string;
},
): Promise<boolean> => {
// Honor an explicit `null` design system — the create panel defaults
@ -903,6 +905,18 @@ function AppInner() {
: {}),
});
}
const userWorkingDir = input.metadata?.userWorkingDir;
if (userWorkingDir) {
try {
await replaceProjectWorkingDir(
result.project.id,
userWorkingDir,
input.userWorkingDirToken,
);
} catch (err) {
console.warn('Failed to set working directory for new project', userWorkingDir, err);
}
}
trackProjectCreateResult(
analytics.track,
{

View file

@ -524,6 +524,7 @@ export function EntryShell({
...(payload.attachments && payload.attachments.length > 0
? { pendingFiles: payload.attachments }
: {}),
...(payload.workingDirToken ? { userWorkingDirToken: payload.workingDirToken } : {}),
autoSendFirstMessage: true,
});
}

View file

@ -107,6 +107,9 @@ interface Props {
contextItemCount: number;
error: string | null;
showActivePluginChip?: boolean;
workingDir?: string | null;
onPickWorkingDir?: () => void;
onClearWorkingDir?: () => void;
}
interface HomeHeroDesignSystemOption {
@ -192,6 +195,9 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
contextItemCount,
error,
showActivePluginChip = true,
workingDir = null,
onPickWorkingDir,
onClearWorkingDir,
},
ref,
) {
@ -1078,6 +1084,31 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, 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}
{activeCreateChip ? (
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
) : null}

View file

@ -41,7 +41,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,
@ -236,6 +237,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('');
@ -926,6 +932,24 @@ 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;
}
const picked = await openFolderDialog();
if (picked) {
setWorkingDir(picked);
setWorkingDirToken(null);
}
}
function updateActiveInputs(next: Record<string, unknown>) {
if (!active) return;
const normalized = active.mediaSurface
@ -1271,6 +1295,8 @@ export function HomeView({
contextMcpServers,
contextConnectors,
attachments: stagedFiles,
...(workingDir ? { workingDir } : {}),
...(workingDirToken ? { workingDirToken } : {}),
});
}
@ -1330,6 +1356,12 @@ export function HomeView({
onPickChip={pickChip}
contextItemCount={contextItemCount}
error={error}
workingDir={workingDir}
onPickWorkingDir={handlePickWorkingDir}
onClearWorkingDir={() => {
setWorkingDir(null);
setWorkingDirToken(null);
}}
/>
<RecentProjectsStrip

View file

@ -41,6 +41,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;
// Files staged on Home before the project exists. App uploads them
// into the created project's Design Files before the first auto-send.
attachments?: File[];

View file

@ -142,6 +142,7 @@ import { AvatarMenu } from './AvatarMenu';
import { HandoffButton } from './HandoffButton';
import { ProjectDesignSystemPicker } from './ProjectDesignSystemPicker';
import { ChatPane } from './ChatPane';
import { WorkingDirPill } from './WorkingDirPill';
import type { ChatSendMeta } from './ChatComposer';
import {
CritiqueTheaterMount,
@ -577,6 +578,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[]>([]);
@ -4513,6 +4518,28 @@ export function ProjectView({
}}
activePluginSnapshot={activePluginSnapshot}
onCollapse={() => setWorkspaceFocused(true)}
composerFooterAccessory={
<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));
}}
/>
}
/>
) : (
<div className="pane" data-testid="chat-pane-loading">
@ -4543,6 +4570,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}
files={projectFiles}
liveArtifacts={liveArtifacts}
filesRefreshKey={filesRefresh}

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,28 +22,6 @@ 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;
}
@ -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" />

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',

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',

View file

@ -540,7 +540,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}',

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',

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',

View file

@ -496,7 +496,7 @@ export const fr: Dict = {
'workingDirPicker.openUnavailable': 'Ouvrez ce projet dans lapp desktop pour afficher le dossier.',
'workingDirPicker.openFailed': 'Impossible dafficher 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',

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',

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',

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',

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',

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',

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',

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',

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',

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',

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',

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',

View file

@ -540,7 +540,7 @@ export const zhCN: Dict = {
'workingDirPicker.openUnavailable': '请在桌面版中打开此项目以显示文件夹。',
'workingDirPicker.openFailed': '无法显示此文件夹',
'workingDirPicker.showInFileManager': '在文件管理器中显示',
'workingDirPicker.replace': '清空并替换目录…',
'workingDirPicker.replace': '替换目录…',
'workingDirPicker.recent': '最近使用的目录',
'handoff.toTarget': '交付给 {target}',
'handoff.openInTarget': '在 {target} 中打开',

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} 中開啟',

View file

@ -931,10 +931,11 @@
.home-hero__foot-left {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
overflow: visible;
}
.home-hero__attach {
appearance: none;
@ -966,17 +967,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);
@ -1098,10 +1098,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);
@ -1143,7 +1144,7 @@
outline: none;
}
.home-hero__footer-option input {
width: 150px;
width: 100px;
}
.home-hero__footer-option--select {
padding-right: 11px;

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;
};
@ -213,6 +231,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>;
@ -359,6 +380,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;
@ -431,6 +473,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,