feat(daemon): add link code folder support for agent context (#455)

* feat(daemon): add link code folder support for agent context

Users can now link local code directories to a project so the AI agent
reads their source code via --add-dir when generating designs. The
import menu's "Link code folder" item opens a native OS folder picker,
and linked folders appear as removable chips below the chat input.

- Add linkedDirs field to ProjectMetadata contract
- Add POST /api/dialog/open-folder endpoint (osascript/zenity/PowerShell)
- Add validateLinkedDirs with path safety checks (absolute, exists, blocklist)
- Append linked dirs to extraAllowedDirs in startChatRun
- Add system prompt hint listing linked code folders
- Render linked folder chips in ChatComposer with add/remove
- Add i18n strings for all 16 locales
- Add 8 unit tests for validateLinkedDirs

* fix: address PR review feedback

- Add JSDoc type annotations to validateLinkedDirs for strict mode
- Check path.isAbsolute before resolve to catch relative inputs
- Allow linking when projectMetadata is undefined (default to prototype)
- Remove redundant PATCH in ProjectView callback

* fix: use inline TS annotations instead of JSDoc in linked-dirs.ts

* fix(daemon): harden linked-dirs validation against security bypasses

- Resolve symlinks with realpathSync.native before checking blocklist
- Reject filesystem root (/) and drive roots as linked dirs
- Canonicalize blocklist entries to handle macOS /etc -> /private/etc
- Validate linkedDirs on project creation, not just PATCH
- Re-validate persisted linkedDirs in startChatRun before use
- Add tests for root, symlink-to-blocked-dir, and realpath resolution

* fix: narrow union type before accessing .error in tests
This commit is contained in:
Justin Gao 2026-05-05 12:46:39 +08:00 committed by GitHub
parent c219be622d
commit cc8add4f09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 484 additions and 28 deletions

View file

@ -0,0 +1,63 @@
import path from 'node:path';
import fs from 'node:fs';
const BLOCKED_CANONICAL = (() => {
const raw =
process.platform === 'win32'
? ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)']
: ['/etc', '/proc', '/sys', '/dev', '/boot'];
const set = new Set<string>(raw);
for (const p of raw) {
try { set.add(fs.realpathSync.native(p)); } catch { /* not resolvable, keep as-is */ }
}
return [...set];
})();
const WIN_ROOT_RE = /^[A-Za-z]:\\?$/;
function isFilesystemRoot(p: string): boolean {
if (process.platform === 'win32') return WIN_ROOT_RE.test(p);
return p === '/';
}
function isBlocked(realPath: string): boolean {
if (isFilesystemRoot(realPath)) return true;
return BLOCKED_CANONICAL.some(
(p: string) =>
realPath === p ||
realPath.startsWith(p + path.sep) ||
p.startsWith(realPath + path.sep),
);
}
export function validateLinkedDirs(
dirs: unknown,
): { dirs: string[]; error?: undefined } | { error: string; dirs?: undefined } {
if (!Array.isArray(dirs)) return { error: 'linkedDirs must be an array' };
const validated: string[] = [];
for (const d of dirs) {
if (typeof d !== 'string' || !d.trim()) {
return { error: 'each linked dir must be a non-empty string' };
}
if (!path.isAbsolute(d)) {
return { error: `linked dir must be an absolute path: ${d}` };
}
let realPath: string;
try {
realPath = fs.realpathSync.native(path.resolve(d));
} catch {
return { error: `directory does not exist or is not accessible: ${d}` };
}
try {
const stat = fs.statSync(realPath);
if (!stat.isDirectory()) return { error: `not a directory: ${d}` };
} catch {
return { error: `directory does not exist or is not accessible: ${d}` };
}
if (isBlocked(realPath)) {
return { error: `system directory not allowed: ${d}` };
}
validated.push(realPath);
}
return { dirs: [...new Set(validated)] };
}

View file

@ -1,7 +1,7 @@
// @ts-nocheck
import express from 'express';
import multer from 'multer';
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@ -21,6 +21,7 @@ import {
spawnEnvForAgent,
} from './agents.js';
import { findSkillById, listSkills } from './skills.js';
import { validateLinkedDirs } from './linked-dirs.js';
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js';
@ -415,6 +416,44 @@ function sanitizeArchiveFilename(raw) {
return cleaned;
}
function openNativeFolderDialog() {
return new Promise((resolve) => {
const platform = process.platform;
if (platform === 'darwin') {
execFile(
'osascript',
['-e', 'POSIX path of (choose folder with prompt "Select a code folder to link")'],
{ timeout: 120_000 },
(err, stdout) => {
if (err) return resolve(null);
const p = stdout.trim().replace(/\/$/, '');
resolve(p || null);
},
);
} else if (platform === 'linux') {
execFile(
'zenity',
['--file-selection', '--directory', '--title=Select a code folder to link'],
{ timeout: 120_000 },
(err, stdout) => {
if (err) return resolve(null);
const p = stdout.trim();
resolve(p || null);
},
);
} else if (platform === 'win32') {
const ps = "Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select a code folder to link'; if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }";
execFile('powershell.exe', ['-NoProfile', '-Command', ps], { timeout: 120_000 }, (err, stdout) => {
if (err) return resolve(null);
const p = stdout.trim();
resolve(p || null);
});
} else {
resolve(null);
}
});
}
/**
* @param {ApiErrorCode} code
* @param {string} message
@ -912,7 +951,18 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
skillId: skillId ?? null,
designSystemId: designSystemId ?? null,
pendingPrompt: pendingPrompt || null,
metadata: metadata && typeof metadata === 'object' ? metadata : null,
metadata:
metadata && typeof metadata === 'object'
? {
...metadata,
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
return v.error ? {} : { linkedDirs: v.dirs };
})()
: {}),
}
: null,
createdAt: now,
updatedAt: now,
});
@ -1040,6 +1090,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.patch('/api/projects/:id', (req, res) => {
try {
const patch = req.body || {};
if (patch.metadata?.linkedDirs) {
const validated = validateLinkedDirs(patch.metadata.linkedDirs);
if (validated.error) {
return sendApiError(res, 400, 'INVALID_LINKED_DIR', validated.error);
}
patch.metadata.linkedDirs = validated.dirs;
}
const project = updateProject(db, req.params.id, patch);
if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
@ -2229,6 +2286,21 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
// Native OS folder picker dialog. Returns { path: string | null }.
app.post('/api/dialog/open-folder', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const selected = await openNativeFolderDialog();
res.json({ path: selected });
} catch (err) {
res
.status(500)
.json({ error: String(err && err.message ? err.message : err) });
}
});
app.post('/api/projects/:id/media/generate', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({
@ -2618,9 +2690,23 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
.map((f) => `- ${f.name}`)
.join('\n')}`
: '\nThis folder is empty. Choose a clear, descriptive filename for whatever you create.';
const projectRecord =
typeof projectId === 'string' && projectId
? getProject(db, projectId)
: null;
const linkedDirs = (() => {
if (!Array.isArray(projectRecord?.metadata?.linkedDirs)) return [];
const v = validateLinkedDirs(projectRecord.metadata.linkedDirs);
return v.dirs ?? [];
})();
const cwdHint = cwd
? `\n\nYour working directory: ${cwd}\nWrite project files relative to it (e.g. \`index.html\`, \`assets/x.png\`). The user can browse those files in real time.${filesListBlock}`
: '';
const linkedDirsHint = linkedDirs.length > 0
? `\n\nLinked code folders (read-only reference code the user wants you to see):\n${
linkedDirs.map((d) => `- \`${d}\``).join('\n')
}`
: '';
const attachmentHint = safeAttachments.length
? `\n\nAttached project files: ${safeAttachments.map((p) => `\`${p}\``).join(', ')}`
: '';
@ -2637,10 +2723,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
.join('\n\n---\n\n');
const composed = [
instructionPrompt
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}\n\n---\n`
? `# Instructions (read first)\n\n${instructionPrompt}${cwdHint}${linkedDirsHint}\n\n---\n`
: cwdHint
? `# Instructions${cwdHint}\n\n---\n`
: '',
? `# Instructions${cwdHint}${linkedDirsHint}\n\n---\n`
: linkedDirsHint
? `# Instructions${linkedDirsHint}\n\n---\n`
: '',
`# User request\n\n${message || '(No extra typed instruction.)'}${attachmentHint}${commentHint}`,
safeImages.length
? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}`
@ -2692,9 +2780,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// no-project runs (packaged daemons / service launches do not start
// their working directory from the workspace root).
const effectiveCwd = cwd ?? PROJECT_ROOT;
const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter((d) =>
fs.existsSync(d),
);
const extraAllowedDirs = [
SKILLS_DIR,
DESIGN_SYSTEMS_DIR,
...linkedDirs,
].filter((d) => fs.existsSync(d));
// Per-agent model + reasoning the user picked in the model menu.
// Trust the value when it matches the most recent /api/agents listing
// (live or fallback). Otherwise allow it through if it passes a

View file

@ -0,0 +1,121 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateLinkedDirs } from '../src/linked-dirs.js';
/** Resolve macOS /var -> /private/var etc. so assertions match realpathSync. */
function real(p: string): string {
try { return realpathSync(p); } catch { return p; }
}
test('rejects non-array input', () => {
assert.equal(validateLinkedDirs('not-array').error, 'linkedDirs must be an array');
assert.equal(validateLinkedDirs(null).error, 'linkedDirs must be an array');
});
test('rejects non-string entries', () => {
assert.equal(validateLinkedDirs([123]).error, 'each linked dir must be a non-empty string');
assert.equal(validateLinkedDirs(['']).error, 'each linked dir must be a non-empty string');
});
test('rejects relative paths', () => {
const result = validateLinkedDirs(['relative/path']);
assert.ok(result.error);
assert.ok(result.error.includes('absolute path'));
});
test('rejects non-existent directories', () => {
const result = validateLinkedDirs(['/no/such/directory/ever']);
assert.ok(result.error);
assert.ok(result.error!.includes('does not exist'));
});
test('rejects files (non-directories)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
const file = join(tmp, 'file.txt');
writeFileSync(file, 'test');
try {
const result = validateLinkedDirs([file]);
assert.ok(result.error);
assert.ok(result.error!.includes('not a directory'));
} finally {
rmSync(tmp, { recursive: true });
}
});
test('rejects filesystem root', () => {
const result = validateLinkedDirs(['/']);
assert.ok(result.error);
assert.ok(result.error.includes('system directory'));
});
test('rejects blocked system directories', () => {
const result = validateLinkedDirs([real('/etc')]);
assert.ok(result.error);
assert.ok(result.error.includes('system directory'));
});
test('rejects symlink pointing to blocked directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
const link = join(tmp, 'etc-link');
try {
symlinkSync('/etc', link);
const result = validateLinkedDirs([link]);
assert.ok(result.error);
assert.ok(result.error.includes('system directory'));
} finally {
rmSync(tmp, { recursive: true });
}
});
test('accepts valid directories and normalizes paths', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
try {
const result = validateLinkedDirs([tmp]);
assert.ok(!result.error);
assert.deepEqual(result.dirs, [real(tmp)]);
} finally {
rmSync(tmp, { recursive: true });
}
});
test('deduplicates entries', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
try {
const result = validateLinkedDirs([tmp, tmp]);
assert.ok(!result.error);
assert.equal(result.dirs!.length, 1);
} finally {
rmSync(tmp, { recursive: true });
}
});
test('resolves and normalizes paths', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
const inner = join(tmp, 'inner');
mkdirSync(inner);
try {
const result = validateLinkedDirs([join(tmp, 'inner', '..') + '/']);
assert.ok(!result.error);
assert.deepEqual(result.dirs, [real(tmp)]);
} finally {
rmSync(tmp, { recursive: true });
}
});
test('resolves symlinks to real paths', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
const inner = join(tmp, 'inner');
const link = join(tmp, 'link');
mkdirSync(inner);
try {
symlinkSync(inner, link);
const result = validateLinkedDirs([link]);
assert.ok(!result.error);
assert.deepEqual(result.dirs, [real(inner)]);
} finally {
rmSync(tmp, { recursive: true });
}
});

View file

@ -8,8 +8,9 @@ import {
} from "react";
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl, uploadProjectFiles } from "../providers/registry";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile } from "../types";
import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry";
import { patchProject } from "../state/projects";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata } from "../types";
import { Icon } from "./Icon";
import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets";
@ -57,6 +58,8 @@ interface Props {
onAdoptPet?: (petId: string) => void;
onTogglePet?: () => void;
onOpenPetSettings?: () => void;
projectMetadata?: ProjectMetadata;
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
}
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
@ -92,6 +95,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
onAdoptPet,
onTogglePet,
onOpenPetSettings,
projectMetadata,
onProjectMetadataChange,
},
ref
) {
@ -123,6 +128,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const petMenuRef = useRef<HTMLDivElement | null>(null);
const petTriggerRef = useRef<HTMLButtonElement | null>(null);
const petEnabled = Boolean(onAdoptPet && onTogglePet);
const linkedDirs = projectMetadata?.linkedDirs ?? [];
// initialDraft is only honored on the first non-empty value the parent
// hands us. After we seed once, the composer is fully under user control
// — re-renders that pass the same prompt back must not reseed. If the
@ -394,6 +400,28 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
if (files.length > 0) void uploadFiles(files);
}
async function handleLinkFolder() {
setImportOpen(false);
if (!projectId) return;
const selected = await openFolderDialog();
if (!selected) return;
const base = projectMetadata ?? { kind: 'prototype' as const };
const existing = base.linkedDirs ?? [];
if (existing.includes(selected)) return;
const metadata: ProjectMetadata = { ...base, linkedDirs: [...existing, selected] };
const result = await patchProject(projectId, { metadata });
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
}
async function handleUnlinkFolder(dir: string) {
if (!projectId) return;
const base = projectMetadata ?? { kind: 'prototype' as const };
const existing = base.linkedDirs ?? [];
const metadata: ProjectMetadata = { ...base, linkedDirs: existing.filter((d) => d !== dir) };
const result = await patchProject(projectId, { metadata });
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
@ -504,6 +532,26 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
t={t}
/>
) : null}
{linkedDirs.length > 0 ? (
<div className="linked-dirs-row" data-testid="linked-dirs">
{linkedDirs.map((dir) => (
<div key={dir} className="linked-dir-chip">
<Icon name="folder" size={13} />
<span className="linked-dir-name" title={dir}>
{dir.split('/').pop() || dir}
</span>
<button
className="staged-remove"
onClick={() => handleUnlinkFolder(dir)}
title={t('chat.linkedFolderRemoveAria', { path: dir })}
aria-label={t('chat.linkedFolderRemoveAria', { path: dir })}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
) : null}
{commentAttachments.length > 0 ? (
<StagedCommentAttachments
attachments={commentAttachments}
@ -628,7 +676,13 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
<ImportItem icon="folder" label={t('chat.importFolder')} t={t} />
<ImportItem
icon="folder"
label={t('chat.importFolder')}
t={t}
enabled
onClick={handleLinkFolder}
/>
<ImportItem
icon="sparkles"
label={t('chat.importSkills')}
@ -840,26 +894,30 @@ function ImportItem({
icon,
label,
t,
enabled,
onClick,
}: {
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
label: string;
t: TranslateFn;
enabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className="composer-import-item"
className={`composer-import-item${enabled ? ' composer-import-item-enabled' : ''}`}
role="menuitem"
tabIndex={-1}
disabled
title={t('chat.importComingSoon')}
onClick={(e) => e.preventDefault()}
disabled={!enabled}
title={enabled ? label : t('chat.importComingSoon')}
onClick={enabled && onClick ? onClick : (e) => e.preventDefault()}
>
<span className="ico" aria-hidden>
<Icon name={icon} size={14} />
</span>
<span className="composer-import-item-label">{label}</span>
<span className="composer-import-item-soon">{t('chat.importSoon')}</span>
{!enabled && <span className="composer-import-item-soon">{t('chat.importSoon')}</span>}
</button>
);
}

View file

@ -3,7 +3,7 @@ import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl } from '../providers/registry';
import type { TodoItem } from '../runtime/todos';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile } from '../types';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import { commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
@ -88,6 +88,8 @@ interface Props {
onAdoptPet?: (petId: string) => void;
onTogglePet?: () => void;
onOpenPetSettings?: () => void;
projectMetadata?: ProjectMetadata;
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
}
type Tab = 'chat' | 'comments';
@ -122,6 +124,8 @@ export function ChatPane({
onAdoptPet,
onTogglePet,
onOpenPetSettings,
projectMetadata,
onProjectMetadataChange,
}: Props) {
const t = useT();
const logRef = useRef<HTMLDivElement | null>(null);
@ -438,6 +442,8 @@ export function ChatPane({
onAdoptPet={onAdoptPet}
onTogglePet={onTogglePet}
onOpenPetSettings={onOpenPetSettings}
projectMetadata={projectMetadata}
onProjectMetadataChange={onProjectMetadataChange}
/>
</>
) : null}

View file

@ -1430,6 +1430,10 @@ export function ProjectView({
onAdoptPet={onAdoptPetInline}
onTogglePet={onTogglePet}
onOpenPetSettings={onOpenPetSettings}
projectMetadata={project.metadata}
onProjectMetadataChange={(metadata) => {
onProjectChange({ ...project, metadata });
}}
/>
<FileWorkspace
projectId={project.id}

View file

@ -397,7 +397,11 @@ export const ar: Dict = {
'chat.importWeb': 'جلب عنصر ويب',
'chat.importFolder': 'ربط مجلد كود',
'chat.importSkills': 'المهارات وأنظمة التصميم',
'chat.importProject': 'مرجع لمشروع آخر',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'إزالة المجلد المرتبط {path}',
'chat.linkedFolderNotFound': 'المجلد غير موجود',
'chat.linkedFolderAlready': 'هذا المجلد مرتبط بالفعل',
'chat.linkedFolderPickError': 'تعذر فتح منتقي المجلدات',
'chat.send': 'إرسال',
'chat.stop': 'إيقاف',
'chat.removeAria': 'إزالة {name}',

View file

@ -397,7 +397,11 @@ export const de: Dict = {
'chat.importWeb': 'Webelement erfassen',
'chat.importFolder': 'Code-Ordner verknüpfen',
'chat.importSkills': 'Skills und Designsysteme',
'chat.importProject': 'Anderes Projekt referenzieren',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Verknüpften Ordner {path} entfernen',
'chat.linkedFolderNotFound': 'Ordner existiert nicht',
'chat.linkedFolderAlready': 'Dieser Ordner ist bereits verknüpft',
'chat.linkedFolderPickError': 'Ordnerauswahl konnte nicht geöffnet werden',
'chat.send': 'Senden',
'chat.stop': 'Stoppen',
'chat.removeAria': '{name} entfernen',

View file

@ -398,6 +398,10 @@ export const en: Dict = {
'chat.importFolder': 'Link code folder',
'chat.importSkills': 'Skills and design systems',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Remove linked folder {path}',
'chat.linkedFolderNotFound': 'Folder does not exist',
'chat.linkedFolderAlready': 'This folder is already linked',
'chat.linkedFolderPickError': 'Could not open folder picker',
'chat.send': 'Send',
'chat.stop': 'Stop',
'chat.removeAria': 'Remove {name}',

View file

@ -398,7 +398,11 @@ export const esES: Dict = {
'chat.importWeb': 'Capturar elemento web',
'chat.importFolder': 'Vincular carpeta de código',
'chat.importSkills': 'Skills y sistemas de diseño',
'chat.importProject': 'Referenciar otro proyecto',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Eliminar carpeta vinculada {path}',
'chat.linkedFolderNotFound': 'La carpeta no existe',
'chat.linkedFolderAlready': 'Esta carpeta ya está vinculada',
'chat.linkedFolderPickError': 'No se pudo abrir el selector de carpetas',
'chat.send': 'Enviar',
'chat.stop': 'Detener',
'chat.removeAria': 'Quitar {name}',

View file

@ -397,7 +397,11 @@ export const fa: Dict = {
'chat.importWeb': 'گرفتن عنصر وب',
'chat.importFolder': 'لینک کردن پوشه کد',
'chat.importSkills': 'مهارت‌ها و سیستم‌های طراحی',
'chat.importProject': 'مرجع یک پروژه دیگر',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'حذف پوشه لینک شده {path}',
'chat.linkedFolderNotFound': 'پوشه وجود ندارد',
'chat.linkedFolderAlready': 'این پوشه قبلاً لینک شده است',
'chat.linkedFolderPickError': 'انتخابگر پوشه باز نشد',
'chat.send': 'ارسال',
'chat.stop': 'توقف',
'chat.removeAria': 'حذف {name}',

View file

@ -398,6 +398,10 @@ export const fr: Dict = {
'chat.importFolder': 'Lier un dossier de code',
'chat.importSkills': 'Compétences et design systems',
'chat.importProject': 'Référencer un autre projet',
'chat.linkedFolderRemoveAria': 'Supprimer le dossier lié {path}',
'chat.linkedFolderNotFound': 'Le dossier n\'existe pas',
'chat.linkedFolderAlready': 'Ce dossier est déjà lié',
'chat.linkedFolderPickError': 'Impossible d\'ouvrir le sélecteur de dossier',
'chat.send': 'Envoyer',
'chat.stop': 'Arrêter',
'chat.removeAria': 'Retirer {name}',

View file

@ -397,7 +397,11 @@ export const hu: Dict = {
'chat.importWeb': 'Webelem mentése',
'chat.importFolder': 'Kódmappa hozzákapcsolása',
'chat.importSkills': 'Skillek és designrendszerek',
'chat.importProject': 'Hivatkozás másik projektre',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Hozzákapcsolt mappa eltávolítása {path}',
'chat.linkedFolderNotFound': 'A mappa nem létezik',
'chat.linkedFolderAlready': 'Ez a mappa már hozzá van kapcsolva',
'chat.linkedFolderPickError': 'Nem sikerült megnyitni a mappaválasztót',
'chat.send': 'Küldés',
'chat.stop': 'Leállítás',
'chat.removeAria': '{name} eltávolítása',

View file

@ -396,7 +396,11 @@ export const ja: Dict = {
'chat.importWeb': 'Web 要素を取得',
'chat.importFolder': 'コードフォルダーをリンク',
'chat.importSkills': 'スキルとデザインシステム',
'chat.importProject': '別のプロジェクトを参照',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'リンクされたフォルダー {path} を削除',
'chat.linkedFolderNotFound': 'フォルダーが存在しません',
'chat.linkedFolderAlready': 'このフォルダーは既にリンクされています',
'chat.linkedFolderPickError': 'フォルダー選択を開けません',
'chat.send': '送信',
'chat.stop': '停止',
'chat.removeAria': '{name} を削除',

View file

@ -397,7 +397,11 @@ export const ko: Dict = {
'chat.importWeb': '웹 요소 가져오기',
'chat.importFolder': '코드 폴더 연결',
'chat.importSkills': '스킬 및 디자인 시스템',
'chat.importProject': '다른 프로젝트 참조',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': '연결된 폴더 {path} 제거',
'chat.linkedFolderNotFound': '폴더가 존재하지 않습니다',
'chat.linkedFolderAlready': '이미 연결된 폴더입니다',
'chat.linkedFolderPickError': '폴더 선택기를 열 수 없습니다',
'chat.send': '전송',
'chat.stop': '중지',
'chat.removeAria': '{name} 제거',

View file

@ -397,7 +397,11 @@ export const pl: Dict = {
'chat.importWeb': 'Pobierz element webowy',
'chat.importFolder': 'Połącz folder z kodem',
'chat.importSkills': 'Umiejętności i systemy projektowania',
'chat.importProject': 'Referencja do innego projektu',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Usuń połączony folder {path}',
'chat.linkedFolderNotFound': 'Folder nie istnieje',
'chat.linkedFolderAlready': 'Ten folder jest już połączony',
'chat.linkedFolderPickError': 'Nie można otworzyć wyboru folderu',
'chat.send': 'Wyślij',
'chat.stop': 'Zatrzymaj',
'chat.removeAria': 'Usuń {name}',

View file

@ -396,7 +396,11 @@ export const ptBR: Dict = {
'chat.importWeb': 'Capturar elemento web',
'chat.importFolder': 'Vincular pasta de código',
'chat.importSkills': 'Skills e sistemas de design',
'chat.importProject': 'Referenciar outro projeto',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Remover pasta vinculada {path}',
'chat.linkedFolderNotFound': 'A pasta não existe',
'chat.linkedFolderAlready': 'Esta pasta já está vinculada',
'chat.linkedFolderPickError': 'Não foi possível abrir o seletor de pasta',
'chat.send': 'Enviar',
'chat.stop': 'Parar',
'chat.removeAria': 'Remover {name}',

View file

@ -396,7 +396,11 @@ export const ru: Dict = {
'chat.importWeb': 'Захватить веб-элемент',
'chat.importFolder': 'Ссылка на папку с кодом',
'chat.importSkills': 'Навыки и дизайн-системы',
'chat.importProject': 'Ссылка на другой проект',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Удалить связанную папку {path}',
'chat.linkedFolderNotFound': 'Папка не существует',
'chat.linkedFolderAlready': 'Эта папка уже связана',
'chat.linkedFolderPickError': 'Не удалось открыть выбор папки',
'chat.send': 'Отправить',
'chat.stop': 'Остановить',
'chat.removeAria': 'Удалить {name}',

View file

@ -396,7 +396,11 @@ export const tr: Dict = {
'chat.importWeb': 'Bir web elementi',
'chat.importFolder': 'Kod klasörünü bağlantıla',
'chat.importSkills': 'Yetenekler ve tasarım sistemleri',
'chat.importProject': 'Bir başka projeyi referans al',
'chat.importProject': 'Reference another project',
'chat.linkedFolderRemoveAria': 'Bağlantılı klasörü kaldır {path}',
'chat.linkedFolderNotFound': 'Klasör mevcut değil',
'chat.linkedFolderAlready': 'Bu klasör zaten bağlantılı',
'chat.linkedFolderPickError': 'Klasör seçici açılamadı',
'chat.send': 'Gönder',
'chat.stop': 'Durdur',
'chat.removeAria': '{name}ı sil',

View file

@ -399,6 +399,10 @@ export const uk: Dict = {
'chat.importFolder': 'Пов\'язати папку коду',
'chat.importSkills': 'Навички та системи дизайну',
'chat.importProject': 'Посилання на інший проект',
'chat.linkedFolderRemoveAria': 'Видалити пов\'язану папку {path}',
'chat.linkedFolderNotFound': 'Папка не існує',
'chat.linkedFolderAlready': 'Ця папка вже пов\'язана',
'chat.linkedFolderPickError': 'Не вдалося відкрити вибір папки',
'chat.send': 'Надіслати',
'chat.stop': 'Зупинити',
'chat.removeAria': 'Видалити {name}',

View file

@ -390,6 +390,10 @@ export const zhCN: Dict = {
'chat.importFolder': '关联代码目录',
'chat.importSkills': '技能与设计体系',
'chat.importProject': '引用其它项目',
'chat.linkedFolderRemoveAria': '移除关联文件夹 {path}',
'chat.linkedFolderNotFound': '文件夹不存在',
'chat.linkedFolderAlready': '该文件夹已关联',
'chat.linkedFolderPickError': '无法打开文件夹选择器',
'chat.send': '发送',
'chat.stop': '停止',
'chat.removeAria': '移除 {name}',

View file

@ -390,6 +390,10 @@ export const zhTW: Dict = {
'chat.importFolder': '關聯程式碼目錄',
'chat.importSkills': '技能與設計系統',
'chat.importProject': '引用其它專案',
'chat.linkedFolderRemoveAria': '移除關聯資料夾 {path}',
'chat.linkedFolderNotFound': '資料夾不存在',
'chat.linkedFolderAlready': '該資料夾已關聯',
'chat.linkedFolderPickError': '無法開啟資料夾選擇器',
'chat.send': '傳送',
'chat.stop': '停止',
'chat.removeAria': '移除 {name}',

View file

@ -444,6 +444,10 @@ export interface Dict {
'chat.importFolder': string;
'chat.importSkills': string;
'chat.importProject': string;
'chat.linkedFolderRemoveAria': string;
'chat.linkedFolderNotFound': string;
'chat.linkedFolderAlready': string;
'chat.linkedFolderPickError': string;
'chat.send': string;
'chat.stop': string;
'chat.removeAria': string;

View file

@ -882,6 +882,39 @@ code {
}
.staged-remove:hover { color: var(--red); background: var(--red-bg); }
.linked-dirs-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.linked-dir-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 11.5px;
color: var(--text-muted);
max-width: 220px;
}
.linked-dir-chip svg { flex-shrink: 0; color: var(--text-muted); }
.linked-dir-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-import-item-enabled {
cursor: pointer;
color: var(--text);
}
.composer-import-item-enabled:hover {
background: var(--bg-subtle);
color: var(--text);
}
.user-attachments {
display: flex;
gap: 6px;

View file

@ -628,6 +628,17 @@ export async function deleteProjectFile(
}
}
export async function openFolderDialog(): Promise<string | null> {
try {
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });
if (!resp.ok) return null;
const data = await resp.json();
return typeof data.path === 'string' && data.path.length > 0 ? data.path : null;
} catch {
return null;
}
}
export async function fetchDesignSystemPreview(id: string): Promise<string | null> {
try {
const resp = await fetch(`/api/design-systems/${encodeURIComponent(id)}/preview`);

View file

@ -78,6 +78,8 @@ export interface ProjectMetadata {
// New Project panel. Treated by the system-prompt composer as a stylistic
// and structural reference for the generation request.
promptTemplate?: PromptTemplateMetadata;
// Absolute paths to local code folders the agent can read via --add-dir.
linkedDirs?: string[];
}
export interface Project {