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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/components/sidebar/view/subcomponents/SidebarNewSession.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import i18next from 'i18next';
import { createElement } from 'react';
import { I18nextProvider } from 'react-i18next';
import { renderToStaticMarkup } from 'react-dom/server';

import deSidebar from '../../../../i18n/locales/de/sidebar.json';
import enSidebar from '../../../../i18n/locales/en/sidebar.json';
import frSidebar from '../../../../i18n/locales/fr/sidebar.json';
import itSidebar from '../../../../i18n/locales/it/sidebar.json';
import jaSidebar from '../../../../i18n/locales/ja/sidebar.json';
import koSidebar from '../../../../i18n/locales/ko/sidebar.json';
import ruSidebar from '../../../../i18n/locales/ru/sidebar.json';
import trSidebar from '../../../../i18n/locales/tr/sidebar.json';
import zhCNSidebar from '../../../../i18n/locales/zh-CN/sidebar.json';
import zhTWSidebar from '../../../../i18n/locales/zh-TW/sidebar.json';

import SidebarNewSession from './SidebarNewSession';

const localeResources = {
de: deSidebar,
en: enSidebar,
fr: frSidebar,
it: itSidebar,
ja: jaSidebar,
ko: koSidebar,
ru: ruSidebar,
tr: trSidebar,
'zh-CN': zhCNSidebar,
'zh-TW': zhTWSidebar,
};

const requiredKeys = [
'open',
'sessionNamePlaceholder',
'workingDirectoryPlaceholder',
'cancel',
'creating',
'create',
'errors.towerUnavailable',
'errors.nameConflict',
'errors.createFailed',
];

const readPath = (value: unknown, keyPath: string): unknown => (
keyPath.split('.').reduce<unknown>((current, segment) => (
current && typeof current === 'object'
? (current as Record<string, unknown>)[segment]
: undefined
), value)
);

test('all supported sidebar locales define the complete new-session form', () => {
for (const [locale, resource] of Object.entries(localeResources)) {
for (const key of requiredKeys) {
const value = readPath(resource.newSessionForm, key);
assert.equal(typeof value, 'string', `${locale} is missing newSessionForm.${key}`);
assert.notEqual(value, '', `${locale} has an empty newSessionForm.${key}`);
}
}
});

const renderForm = async (locale: 'en' | 'ko') => {
const instance = i18next.createInstance();
await instance.init({
lng: locale,
fallbackLng: false,
resources: {
[locale]: { sidebar: localeResources[locale] },
},
ns: ['sidebar'],
defaultNS: 'sidebar',
interpolation: { escapeValue: false },
});

return renderToStaticMarkup(
createElement(
I18nextProvider,
{ i18n: instance },
createElement(SidebarNewSession, { onCreated: () => {}, initiallyOpen: true }),
),
);
};

test('English and Korean render the new-session form without mixed-language copy', async () => {
const english = await renderForm('en');
assert.ok(english.includes(enSidebar.newSessionForm.sessionNamePlaceholder));
assert.ok(english.includes(enSidebar.newSessionForm.workingDirectoryPlaceholder));
assert.ok(english.includes(enSidebar.newSessionForm.cancel));
assert.ok(english.includes(enSidebar.newSessionForm.create));
assert.ok(!english.includes(koSidebar.newSessionForm.sessionNamePlaceholder));
assert.ok(!english.includes(koSidebar.newSessionForm.cancel));

const korean = await renderForm('ko');
assert.ok(korean.includes(koSidebar.newSessionForm.sessionNamePlaceholder));
assert.ok(korean.includes(koSidebar.newSessionForm.workingDirectoryPlaceholder));
assert.ok(korean.includes(koSidebar.newSessionForm.cancel));
assert.ok(korean.includes(koSidebar.newSessionForm.create));
assert.ok(!korean.includes(enSidebar.newSessionForm.sessionNamePlaceholder));
assert.ok(!korean.includes(enSidebar.newSessionForm.cancel));
});
39 changes: 27 additions & 12 deletions src/components/sidebar/view/subcomponents/SidebarNewSession.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { api } from '../../../../utils/api';
import HomeDirInput from '../../../../shared/view/HomeDirInput';
Expand All @@ -25,8 +26,15 @@ const PROVIDERS: { id: SpawnProvider; label: string }[] = [
* Unified new-session form. GJC boots through the control tower; every other
* provider boots its native CLI in tmux through /sessions/external/spawn.
*/
export default function SidebarNewSession({ onCreated }: { onCreated: () => void }) {
const [open, setOpen] = useState(false);
export default function SidebarNewSession({
onCreated,
initiallyOpen = false,
}: {
onCreated: () => void;
initiallyOpen?: boolean;
}) {
const { t } = useTranslation('sidebar');
const [open, setOpen] = useState(initiallyOpen);
const [provider, setProvider] = useState<SpawnProvider>('gjc');
const [name, setName] = useState('');
const [cwd, setCwd] = useState('');
Expand Down Expand Up @@ -61,10 +69,12 @@ export default function SidebarNewSession({ onCreated }: { onCreated: () => void
return;
}
const text = data.reachable === false
? '관제탑 미가동 — 생성 불가'
? t('newSessionForm.errors.towerUnavailable')
: data.conflict
? '같은 이름의 세션이 이미 있습니다'
: (typeof body?.error === 'string' && body.error) || data.detail || '세션 생성 실패';
? t('newSessionForm.errors.nameConflict')
: (typeof body?.error === 'string' && body.error)
|| data.detail
|| t('newSessionForm.errors.createFailed');
setStatus({ kind: 'error', text });
return;
}
Expand All @@ -77,9 +87,12 @@ export default function SidebarNewSession({ onCreated }: { onCreated: () => void
onCreated();
return;
}
setStatus({ kind: 'error', text: body?.error?.message ?? body?.message ?? '세션 생성 실패' });
setStatus({
kind: 'error',
text: body?.error?.message ?? body?.message ?? t('newSessionForm.errors.createFailed'),
});
} catch {
setStatus({ kind: 'error', text: '세션 생성 실패' });
setStatus({ kind: 'error', text: t('newSessionForm.errors.createFailed') });
}
};

Expand All @@ -91,7 +104,7 @@ export default function SidebarNewSession({ onCreated }: { onCreated: () => void
onClick={() => setOpen(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border px-2 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />새 세션
<Plus className="h-3.5 w-3.5" />{t('newSessionForm.open')}
</button>
</div>
);
Expand Down Expand Up @@ -119,14 +132,14 @@ export default function SidebarNewSession({ onCreated }: { onCreated: () => void
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="세션 이름 (영숫자, 예: my-feature)"
placeholder={t('newSessionForm.sessionNamePlaceholder')}
className="w-full rounded-md border border-border bg-transparent px-2 py-1.5 text-sm outline-none focus:border-primary/60"
/>
<HomeDirInput
value={cwd}
onChange={setCwd}
onSubmit={() => void spawn()}
placeholder="작업 폴더 (예: ~/workspace/my-proj, 절대경로 가능)"
placeholder={t('newSessionForm.workingDirectoryPlaceholder')}
/>
{status.kind === 'error' && <p className="text-[11px] text-red-500">{status.text}</p>}
<div className="flex items-center justify-end gap-2">
Expand All @@ -135,15 +148,17 @@ export default function SidebarNewSession({ onCreated }: { onCreated: () => void
onClick={() => { setOpen(false); reset(); }}
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
취소
{t('newSessionForm.cancel')}
</button>
<button
type="button"
onClick={() => void spawn()}
disabled={!name.trim() || !cwd.trim() || status.kind === 'spawning'}
className="rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{status.kind === 'spawning' ? '생성 중…' : '만들기'}
{status.kind === 'spawning'
? t('newSessionForm.creating')
: t('newSessionForm.create')}
</button>
</div>
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/i18n/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ import enCodeEditor from './locales/en/codeEditor.json';
// eslint-disable-next-line import-x/order
import enTasks from './locales/en/tasks.json';

import frCommon from './locales/fr/common.json';
import frSettings from './locales/fr/settings.json';
import frAuth from './locales/fr/auth.json';
import frSidebar from './locales/fr/sidebar.json';
import frChat from './locales/fr/chat.json';
import frCodeEditor from './locales/fr/codeEditor.json';
// eslint-disable-next-line import-x/order
import frTasks from './locales/fr/tasks.json';

import koCommon from './locales/ko/common.json';
import koSettings from './locales/ko/settings.json';
import koAuth from './locales/ko/auth.json';
Expand Down Expand Up @@ -124,6 +133,15 @@ i18n
codeEditor: enCodeEditor,
tasks: enTasks,
},
fr: {
common: frCommon,
settings: frSettings,
auth: frAuth,
sidebar: frSidebar,
chat: frChat,
codeEditor: frCodeEditor,
tasks: frTasks,
},
ko: {
common: koCommon,
settings: koSettings,
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/de/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert – zum Anwenden Server neu starten"
},
"newSessionForm": {
"open": "Neue Sitzung",
"sessionNamePlaceholder": "Sitzungsname (Buchstaben und Zahlen, z. B. mein-feature)",
"workingDirectoryPlaceholder": "Arbeitsordner (z. B. ~/workspace/mein-projekt oder absoluter Pfad)",
"cancel": "Abbrechen",
"creating": "Wird erstellt…",
"create": "Erstellen",
"errors": {
"towerUnavailable": "Kontrollzentrum läuft nicht — Sitzung kann nicht erstellt werden",
"nameConflict": "Eine Sitzung mit diesem Namen existiert bereits",
"createFailed": "Sitzung konnte nicht erstellt werden"
}
},
"search": {
"modeProjects": "Projekte",
"modeConversations": "Unterhaltungen",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/en/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
},
"newSessionForm": {
"open": "New session",
"sessionNamePlaceholder": "Session name (letters and numbers, e.g. my-feature)",
"workingDirectoryPlaceholder": "Working folder (e.g. ~/workspace/my-proj or an absolute path)",
"cancel": "Cancel",
"creating": "Creating…",
"create": "Create",
"errors": {
"towerUnavailable": "Control tower is not running — cannot create session",
"nameConflict": "A session with this name already exists",
"createFailed": "Failed to create session"
}
},
"search": {
"modeProjects": "Projects",
"modeConversations": "Conversations",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/fr/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
},
"newSessionForm": {
"open": "Nouvelle session",
"sessionNamePlaceholder": "Nom de session (lettres et chiffres, ex. ma-fonctionnalite)",
"workingDirectoryPlaceholder": "Dossier de travail (ex. ~/workspace/mon-projet ou chemin absolu)",
"cancel": "Annuler",
"creating": "Création…",
"create": "Créer",
"errors": {
"towerUnavailable": "La tour de contrôle n'est pas active — création impossible",
"nameConflict": "Une session portant ce nom existe déjà",
"createFailed": "Échec de la création de la session"
}
},
"search": {
"modeProjects": "Projets",
"modeConversations": "Conversations",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/it/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
},
"newSessionForm": {
"open": "Nuova sessione",
"sessionNamePlaceholder": "Nome sessione (lettere e numeri, es. mia-funzione)",
"workingDirectoryPlaceholder": "Cartella di lavoro (es. ~/workspace/mio-progetto o percorso assoluto)",
"cancel": "Annulla",
"creating": "Creazione…",
"create": "Crea",
"errors": {
"towerUnavailable": "La torre di controllo non è in esecuzione — impossibile creare la sessione",
"nameConflict": "Esiste già una sessione con questo nome",
"createFailed": "Creazione della sessione non riuscita"
}
},
"search": {
"modeProjects": "Progetti",
"modeConversations": "Conversazioni",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/ja/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@
"updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
},
"newSessionForm": {
"open": "新しいセッション",
"sessionNamePlaceholder": "セッション名(英数字、例: my-feature)",
"workingDirectoryPlaceholder": "作業フォルダー(例: ~/workspace/my-proj、絶対パスも可)",
"cancel": "キャンセル",
"creating": "作成中…",
"create": "作成",
"errors": {
"towerUnavailable": "コントロールタワーが停止中のため作成できません",
"nameConflict": "同じ名前のセッションがすでに存在します",
"createFailed": "セッションの作成に失敗しました"
}
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除去",
"deleteSession": "セッションを削除",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/ko/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@
"updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
},
"newSessionForm": {
"open": "새 세션",
"sessionNamePlaceholder": "세션 이름 (영숫자, 예: my-feature)",
"workingDirectoryPlaceholder": "작업 폴더 (예: ~/workspace/my-proj, 절대 경로 가능)",
"cancel": "취소",
"creating": "생성 중…",
"create": "만들기",
"errors": {
"towerUnavailable": "관제탑 미가동 — 생성 불가",
"nameConflict": "같은 이름의 세션이 이미 있습니다",
"createFailed": "세션 생성 실패"
}
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제거",
"deleteSession": "세션 삭제",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/ru/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
},
"newSessionForm": {
"open": "Новый сеанс",
"sessionNamePlaceholder": "Имя сеанса (буквы и цифры, например my-feature)",
"workingDirectoryPlaceholder": "Рабочая папка (например ~/workspace/my-proj или абсолютный путь)",
"cancel": "Отмена",
"creating": "Создание…",
"create": "Создать",
"errors": {
"towerUnavailable": "Центр управления не запущен — создать сеанс невозможно",
"nameConflict": "Сеанс с таким именем уже существует",
"createFailed": "Не удалось создать сеанс"
}
},
"search": {
"modeProjects": "Проекты",
"modeConversations": "Разговоры",
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/locales/tr/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@
"updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
},
"newSessionForm": {
"open": "Yeni oturum",
"sessionNamePlaceholder": "Oturum adı (harf ve rakamlar, ör. benim-ozelligim)",
"workingDirectoryPlaceholder": "Çalışma klasörü (ör. ~/workspace/projem veya mutlak yol)",
"cancel": "İptal",
"creating": "Oluşturuluyor…",
"create": "Oluştur",
"errors": {
"towerUnavailable": "Kontrol kulesi çalışmıyor — oturum oluşturulamıyor",
"nameConflict": "Bu ada sahip bir oturum zaten var",
"createFailed": "Oturum oluşturulamadı"
}
},
"search": {
"modeProjects": "Projeler",
"modeConversations": "Konuşmalar",
Expand Down
Loading