newpr 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/analyzer/pipeline.ts +1 -0
  3. package/src/config/index.ts +1 -0
  4. package/src/config/store.ts +1 -0
  5. package/src/llm/prompts.ts +10 -4
  6. package/src/types/config.ts +1 -0
  7. package/src/web/client/components/AppShell.tsx +29 -22
  8. package/src/web/client/components/ChatSection.tsx +18 -12
  9. package/src/web/client/components/DetailPane.tsx +10 -6
  10. package/src/web/client/components/ErrorScreen.tsx +15 -41
  11. package/src/web/client/components/FeasibilityAlert.tsx +5 -3
  12. package/src/web/client/components/InputScreen.tsx +21 -17
  13. package/src/web/client/components/LoadingTimeline.tsx +22 -17
  14. package/src/web/client/components/ResultsScreen.tsx +31 -20
  15. package/src/web/client/components/ReviewModal.tsx +23 -15
  16. package/src/web/client/components/SettingsPanel.tsx +100 -25
  17. package/src/web/client/lib/i18n/context.tsx +76 -0
  18. package/src/web/client/lib/i18n/en.ts +276 -0
  19. package/src/web/client/lib/i18n/index.ts +3 -0
  20. package/src/web/client/lib/i18n/ko.ts +274 -0
  21. package/src/web/client/main.tsx +4 -1
  22. package/src/web/client/panels/CartoonPanel.tsx +12 -10
  23. package/src/web/client/panels/DiscussionPanel.tsx +14 -12
  24. package/src/web/client/panels/FilesPanel.tsx +14 -9
  25. package/src/web/client/panels/GroupsPanel.tsx +3 -1
  26. package/src/web/client/panels/SlidesPanel.tsx +17 -15
  27. package/src/web/client/panels/StackPanel.tsx +50 -44
  28. package/src/web/client/panels/StoryPanel.tsx +5 -3
  29. package/src/web/server/routes.ts +27 -21
  30. package/src/web/styles/built.css +1 -1
@@ -0,0 +1,274 @@
1
+ import type { Translations } from "./en.ts";
2
+
3
+ export const ko: Translations = {
4
+ common: {
5
+ close: "닫기",
6
+ cancel: "취소",
7
+ save: "저장",
8
+ retry: "재시도",
9
+ tryAgain: "다시 시도",
10
+ back: "뒤로",
11
+ submit: "제출",
12
+ download: "다운로드",
13
+ regenerate: "재생성",
14
+ generate: "생성",
15
+ enabled: "사용",
16
+ disabled: "사용 안함",
17
+ refresh: "새로고침",
18
+ retrying: "재시도 중...",
19
+ },
20
+
21
+ time: {
22
+ justNow: "방금",
23
+ minutes: "{n}분",
24
+ minutesAgo: "{n}분 전",
25
+ hours: "{n}시간",
26
+ hoursAgo: "{n}시간 전",
27
+ days: "{n}일",
28
+ daysAgo: "{n}일 전",
29
+ monthsAgo: "{n}개월 전",
30
+ },
31
+
32
+ appShell: {
33
+ noAnalysesYet: "분석 기록 없음",
34
+ newAnalysis: "새 분석",
35
+ restarting: "재시작 중...",
36
+ versionAvailable: "v{version} 사용 가능",
37
+ updating: "업데이트 중...",
38
+ updateAndRestart: "업데이트 및 재시작",
39
+ chatResponding: "채팅 응답 중...",
40
+ switchToMode: "{mode} 모드로 전환",
41
+ settings: "설정",
42
+ },
43
+
44
+ input: {
45
+ tagline: "PR을 읽기 쉬운 스토리로",
46
+ enterToAnalyze: "↵ Enter로 분석 시작",
47
+ status: "상태",
48
+ recent: "최근",
49
+ placeholder: "https://github.com/owner/repo/pull/123",
50
+ sponsorTagline: "The Power of AI for Every Business",
51
+ ad: "광고",
52
+ },
53
+
54
+ settings: {
55
+ title: "설정",
56
+ authentication: "인증",
57
+ openrouterApiKey: "OpenRouter API Key",
58
+ githubToken: "GitHub Token",
59
+ configured: "설정됨",
60
+ notSet: "미설정",
61
+ notDetected: "감지되지 않음",
62
+ change: "변경",
63
+ set: "설정",
64
+ ghCli: "gh CLI",
65
+ model: "Model",
66
+ llm: "LLM",
67
+ setApiKeyFirst: "API Key를 먼저 설정하세요",
68
+ agent: "Agent",
69
+ language: "Language",
70
+ autoDetect: "자동 감지",
71
+ limits: "제한",
72
+ maxFiles: "최대 파일 수",
73
+ timeout: "Timeout",
74
+ seconds: "초",
75
+ concurrency: "Concurrency",
76
+ customPrompt: "Custom Prompt",
77
+ customPromptDesc: "PR 분석에 적용될 추가 지침입니다. 모든 분석 단계에 적용됩니다.",
78
+ customPromptPlaceholder: "예: 보안 취약점과 성능 이슈에 집중해주세요. 스타일 변경은 무시해주세요.",
79
+ cmdEnterToSave: "Cmd+Enter로 저장하거나 바깥을 클릭하세요",
80
+ plugins: "Plugins",
81
+ privacy: "개인정보",
82
+ usageAnalytics: "Usage Analytics",
83
+ searchModels: "모델 검색...",
84
+ noModelsFound: "모델을 찾을 수 없습니다",
85
+ uiLanguage: "UI Language",
86
+ interface: "인터페이스",
87
+ },
88
+
89
+ review: {
90
+ submitReview: "Review 제출",
91
+ approve: "Approve",
92
+ requestChanges: "Request Changes",
93
+ comment: "Comment",
94
+ approveDesc: "이 PR을 승인합니다",
95
+ requestChangesDesc: "반드시 수정해야 할 피드백을 제출합니다",
96
+ commentDesc: "일반 피드백을 제출합니다",
97
+ reviewSubmitted: "Review가 제출되었습니다",
98
+ viewOnGithub: "GitHub에서 보기",
99
+ message: "메시지",
100
+ optionalMessage: "선택 사항...",
101
+ describeChanges: "필요한 변경 사항을 설명해주세요...",
102
+ ctrlEnterToSubmit: "{key}+Enter로 제출",
103
+ },
104
+
105
+ loading: {
106
+ fetching: "PR 데이터 가져오기",
107
+ parsing: "변경 내역 분석",
108
+ cloning: "리포지토리 복제",
109
+ checkout: "브랜치 체크아웃",
110
+ exploring: "코드베이스 탐색",
111
+ analyzing: "파일 분석",
112
+ grouping: "변경 그룹화",
113
+ summarizing: "요약 생성",
114
+ narrating: "내러티브 작성",
115
+ done: "완료",
116
+ },
117
+
118
+ error: {
119
+ rateLimitTitle: "요청 제한 초과",
120
+ rateLimitHint: "API 요청 제한이 초과되었습니다. 잠시 후 다시 시도해주세요.",
121
+ timeoutTitle: "요청 시간 초과",
122
+ timeoutHint: "분석 시간이 초과되었습니다. 매우 큰 PR에서 발생할 수 있습니다.",
123
+ networkTitle: "연결 실패",
124
+ networkHint: "서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.",
125
+ authTitle: "인증 오류",
126
+ authHint: "GitHub 토큰이 만료되었거나 유효하지 않을 수 있습니다. `newpr auth`로 재설정해주세요.",
127
+ notFoundTitle: "PR을 찾을 수 없음",
128
+ notFoundHint: "PR을 찾을 수 없습니다. URL을 확인하고 접근 권한이 있는지 확인해주세요.",
129
+ apiKeyTitle: "API Key 오류",
130
+ apiKeyHint: "OpenRouter API Key가 없거나 유효하지 않을 수 있습니다. 환경 변수에 OPENROUTER_API_KEY를 설정해주세요.",
131
+ defaultTitle: "분석 실패",
132
+ defaultHint: "분석 중 오류가 발생했습니다.",
133
+ unknownError: "알 수 없는 오류가 발생했습니다",
134
+ },
135
+
136
+ chat: {
137
+ title: "채팅",
138
+ askAnything: "이 PR에 대해 무엇이든 물어보세요",
139
+ refAndCmds: "@로 파일 참조 · /로 명령어",
140
+ askAboutPr: "이 PR에 대해 질문하세요...",
141
+ atToRef: "@로 참조 · /로 명령어",
142
+ enterToSend: "Enter로 전송",
143
+ previousAnalysis: "이전 분석",
144
+ messagesCompacted: "{count}개의 메시지가 압축됨",
145
+ conversationCompacted: "대화가 압축됨",
146
+ thinking: "생각 중…",
147
+ done: "완료",
148
+ undoLabel: "/undo",
149
+ undoDesc: "마지막 대화 삭제",
150
+ },
151
+
152
+ results: {
153
+ open: "Open",
154
+ merged: "Merged",
155
+ closed: "Closed",
156
+ draft: "Draft",
157
+ story: "스토리",
158
+ discussion: "토론",
159
+ groups: "그룹",
160
+ files: "파일",
161
+ stack: "스택",
162
+ slides: "슬라이드",
163
+ comic: "만화",
164
+ review: "리뷰",
165
+ nFiles: "{n}개 파일",
166
+ prUpdated: "이 분석이 생성된 이후 PR이 업데이트되었습니다.",
167
+ reAnalyze: "재분석",
168
+ },
169
+
170
+ detail: {
171
+ keyChanges: "주요 변경 사항",
172
+ risk: "위험도",
173
+ dependencies: "의존성",
174
+ nFiles: "{n}개 파일",
175
+ loadingDiff: "변경 내역 불러오는 중",
176
+ },
177
+
178
+ story: {
179
+ scope: "범위",
180
+ impact: "영향",
181
+ walkthrough: "상세 설명",
182
+ },
183
+
184
+ discussion: {
185
+ noSession: "세션을 사용할 수 없습니다",
186
+ loadingDiscussion: "토론 불러오는 중",
187
+ description: "설명",
188
+ comments: "댓글",
189
+ noContent: "설명 또는 댓글이 없습니다",
190
+ },
191
+
192
+ groups: {
193
+ nGroups: "{n}개 그룹",
194
+ },
195
+
196
+ files: {
197
+ nFiles: "{n}개 파일",
198
+ tree: "트리",
199
+ groups: "그룹",
200
+ changes: "변경 사항",
201
+ ungrouped: "미분류",
202
+ },
203
+
204
+ cartoon: {
205
+ title: "만화 스트립",
206
+ description: "이 PR의 주요 변경 사항을 시각화하는 4컷 만화를 생성합니다. Gemini 기반.",
207
+ generating: "만화 생성 중...",
208
+ takesTime: "10-30초 소요될 수 있습니다",
209
+ failed: "생성 실패",
210
+ altText: "PR 4컷 만화",
211
+ },
212
+
213
+ slides: {
214
+ title: "슬라이드 덱",
215
+ description: "이 PR을 팀에 설명하는 프레젠테이션을 생성합니다. 슬라이드 수는 PR 복잡성에 따라 자동으로 결정됩니다.",
216
+ generateSlides: "슬라이드 생성",
217
+ failed: "생성 실패",
218
+ starting: "시작 중...",
219
+ resuming: "실패한 슬라이드 재생성 중...",
220
+ takesMinutes: "몇 분 소요될 수 있습니다",
221
+ slidesFailed: "{n}개 슬라이드 생성 실패",
222
+ retryFailed: "실패 항목 재시도",
223
+ slidesReady: "슬라이드 완료",
224
+ nSlidesGenerated: "{n}개 슬라이드 생성됨",
225
+ },
226
+
227
+ stack: {
228
+ title: "PR 스태킹",
229
+ subtitle: "집중적이고 리뷰 가능한 PR로 분할",
230
+ description: "분석 그룹을 기반으로 이 PR을 더 작은 초안 PR 스택으로 자동 분할합니다. 각 그룹은 적절한 의존성 순서로 독립된 PR이 됩니다.",
231
+ maxPrs: "최대 PR 수",
232
+ startStacking: "스태킹 시작",
233
+ partition: "파티션",
234
+ partitionDesc: "파일을 그룹에 할당 중",
235
+ plan: "계획",
236
+ planDesc: "스택 계획 수립 중",
237
+ execute: "실행",
238
+ executeDesc: "커밋 생성 중",
239
+ publish: "배포",
240
+ publishDesc: "브랜치 푸시 및 초안 PR 생성 중",
241
+ ready: "준비 완료",
242
+ published: "배포됨",
243
+ publishingInfo: "스택 브랜치를 업로드하고 초안 PR을 생성 중입니다. 아래에 결과가 나타날 때까지 기다려주세요.",
244
+ publishAsDraft: "초안 PR로 배포",
245
+ nPrs: "{n}개 PR",
246
+ nFilesTotal: "총 {n}개 파일",
247
+ descriptionPreview: "설명 미리보기",
248
+ preparingPreview: "미리보기 본문 준비 중...",
249
+ draftPublishResults: "초안 PR 배포 결과",
250
+ nOfNCreated: "{created}/{total} 생성됨",
251
+ closeAll: "모두 닫기",
252
+ closeDeleteBranches: "닫기 + 브랜치 삭제",
253
+ confirmCloseAll: "모든 스택 초안 PR을 닫으시겠습니까?",
254
+ confirmCloseDelete: "모든 스택 초안 PR을 닫고 브랜치를 삭제하시겠습니까?",
255
+ noDraftPrUrls: "초안 PR URL이 반환되지 않았습니다.",
256
+ branchesPushedNotCreated: "일부 브랜치가 푸시되었지만 PR 생성이 완료되지 않았습니다.",
257
+ envVars: "환경 변수",
258
+ envVarsDesc: "품질 게이트 스크립트를 위한 환경 변수 설정 (예: NPM_TOKEN, CI 토큰)",
259
+ addVariable: "변수 추가",
260
+ treeEquivalenceVerified: "트리 동등성 확인됨",
261
+ verificationFailed: "검증 실패: {errors}",
262
+ qualityGate: "Quality Gate",
263
+ qualityGateSkipped: "Quality Gate 건너뜀: {reason}",
264
+ qualityGateAllPassed: "모든 {n}개 그룹 통과",
265
+ qualityGatePartial: "{passed}/{total}개 그룹 통과",
266
+ groupsWithWarnings: "{n}개 그룹 경고 (비차단)",
267
+ },
268
+
269
+ feasibility: {
270
+ feasible: "실행 가능",
271
+ notFeasible: "실행 불가 — 순환 의존성",
272
+ unassignedFiles: "{n}개 미할당 파일",
273
+ },
274
+ };
@@ -1,12 +1,15 @@
1
1
  import React from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { App } from "./App.tsx";
4
+ import { I18nProvider } from "./lib/i18n/index.ts";
4
5
 
5
6
  const el = document.getElementById("root");
6
7
  if (el) {
7
8
  createRoot(el).render(
8
9
  <React.StrictMode>
9
- <App />
10
+ <I18nProvider>
11
+ <App />
12
+ </I18nProvider>
10
13
  </React.StrictMode>,
11
14
  );
12
15
  }
@@ -1,11 +1,13 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Loader2, Sparkles, RefreshCw, Download, AlertCircle } from "lucide-react";
3
3
  import type { NewprOutput } from "../../../types/output.ts";
4
+ import { useI18n } from "../lib/i18n/index.ts";
4
5
 
5
6
  export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
6
7
  const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
7
8
  const [imageUrl, setImageUrl] = useState<string | null>(null);
8
9
  const [error, setError] = useState<string | null>(null);
10
+ const { t } = useI18n();
9
11
 
10
12
  useEffect(() => {
11
13
  if (data.cartoon) {
@@ -61,9 +63,9 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
61
63
  <div className="pt-8 flex flex-col items-center">
62
64
  <div className="w-full max-w-sm space-y-6">
63
65
  <div className="space-y-2">
64
- <h3 className="text-xs font-medium">Comic Strip</h3>
66
+ <h3 className="text-xs font-medium">{t("cartoon.title")}</h3>
65
67
  <p className="text-[11px] text-muted-foreground/60 leading-relaxed">
66
- Generate a 4-panel comic strip that visualizes the key changes in this PR. Powered by Gemini.
68
+ {t("cartoon.description")}
67
69
  </p>
68
70
  </div>
69
71
  <button
@@ -72,7 +74,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
72
74
  className="w-full flex items-center justify-center gap-2 h-9 rounded-lg bg-foreground text-background text-xs font-medium hover:opacity-90 transition-opacity"
73
75
  >
74
76
  <Sparkles className="h-3.5 w-3.5" />
75
- Generate
77
+ {t("common.generate")}
76
78
  </button>
77
79
  </div>
78
80
  </div>
@@ -86,8 +88,8 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
86
88
  <div className="aspect-[4/3] rounded-lg border border-dashed border-border/60 flex flex-col items-center justify-center gap-3">
87
89
  <Loader2 className="h-5 w-5 animate-spin text-muted-foreground/40" />
88
90
  <div className="text-center space-y-1">
89
- <p className="text-xs text-muted-foreground/60">Generating comic...</p>
90
- <p className="text-[10px] text-muted-foreground/30">This may take 10-30 seconds</p>
91
+ <p className="text-xs text-muted-foreground/60">{t("cartoon.generating")}</p>
92
+ <p className="text-[10px] text-muted-foreground/30">{t("cartoon.takesTime")}</p>
91
93
  </div>
92
94
  </div>
93
95
  </div>
@@ -102,7 +104,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
102
104
  <div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 flex items-start gap-2.5">
103
105
  <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
104
106
  <div className="space-y-1 min-w-0">
105
- <p className="text-xs text-destructive font-medium">Generation failed</p>
107
+ <p className="text-xs text-destructive font-medium">{t("cartoon.failed")}</p>
106
108
  <p className="text-[11px] text-destructive/70 break-words">{error}</p>
107
109
  </div>
108
110
  </div>
@@ -112,7 +114,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
112
114
  className="w-full flex items-center justify-center gap-2 h-9 rounded-lg border text-xs text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
113
115
  >
114
116
  <RefreshCw className="h-3 w-3" />
115
- Try again
117
+ {t("common.tryAgain")}
116
118
  </button>
117
119
  </div>
118
120
  </div>
@@ -125,7 +127,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
125
127
  <div className="rounded-lg border overflow-hidden">
126
128
  <img
127
129
  src={imageUrl}
128
- alt="PR 4-panel comic"
130
+ alt={t("cartoon.altText")}
129
131
  className="w-full"
130
132
  />
131
133
  </div>
@@ -137,7 +139,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
137
139
  className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
138
140
  >
139
141
  <Download className="h-3 w-3" />
140
- Download
142
+ {t("common.download")}
141
143
  </button>
142
144
  <button
143
145
  type="button"
@@ -145,7 +147,7 @@ export function CartoonPanel({ data, sessionId }: { data: NewprOutput; sessionId
145
147
  className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
146
148
  >
147
149
  <RefreshCw className="h-3 w-3" />
148
- Regenerate
150
+ {t("common.regenerate")}
149
151
  </button>
150
152
  </div>
151
153
  </div>
@@ -2,21 +2,22 @@ import { useState, useEffect, useCallback } from "react";
2
2
  import { Markdown } from "../components/Markdown.tsx";
3
3
  import { RefreshCw, ExternalLink, AlertCircle, Loader2 } from "lucide-react";
4
4
  import type { PrComment } from "../../../types/github.ts";
5
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
5
6
 
6
7
  interface DiscussionData {
7
8
  body: string;
8
9
  comments: PrComment[];
9
10
  }
10
11
 
11
- function timeAgo(dateStr: string): string {
12
+ function timeAgo(dateStr: string, t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
12
13
  const diff = Date.now() - new Date(dateStr).getTime();
13
14
  const mins = Math.floor(diff / 60_000);
14
- if (mins < 1) return "just now";
15
- if (mins < 60) return `${mins}m ago`;
15
+ if (mins < 1) return t("time.justNow");
16
+ if (mins < 60) return t("time.minutesAgo", { n: mins });
16
17
  const hours = Math.floor(mins / 60);
17
- if (hours < 24) return `${hours}h ago`;
18
+ if (hours < 24) return t("time.hoursAgo", { n: hours });
18
19
  const days = Math.floor(hours / 24);
19
- if (days < 30) return `${days}d ago`;
20
+ if (days < 30) return t("time.daysAgo", { n: days });
20
21
  return new Date(dateStr).toLocaleDateString();
21
22
  }
22
23
 
@@ -24,6 +25,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
24
25
  const [data, setData] = useState<DiscussionData | null>(null);
25
26
  const [loading, setLoading] = useState(false);
26
27
  const [error, setError] = useState<string | null>(null);
28
+ const { t } = useI18n();
27
29
 
28
30
  const fetchDiscussion = useCallback(async () => {
29
31
  if (!sessionId) return;
@@ -53,7 +55,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
53
55
  if (!sessionId) {
54
56
  return (
55
57
  <div className="flex flex-col items-center justify-center py-20">
56
- <p className="text-sm text-muted-foreground/50">No session available</p>
58
+ <p className="text-sm text-muted-foreground/50">{t("discussion.noSession")}</p>
57
59
  </div>
58
60
  );
59
61
  }
@@ -62,7 +64,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
62
64
  return (
63
65
  <div className="flex items-center justify-center py-20 gap-2">
64
66
  <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
65
- <span className="text-sm text-muted-foreground/50">Loading discussion</span>
67
+ <span className="text-sm text-muted-foreground/50">{t("discussion.loadingDiscussion")}</span>
66
68
  </div>
67
69
  );
68
70
  }
@@ -80,7 +82,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
80
82
  className="inline-flex items-center gap-1.5 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
81
83
  >
82
84
  <RefreshCw className="h-3 w-3" />
83
- Retry
85
+ {t("common.retry")}
84
86
  </button>
85
87
  </div>
86
88
  );
@@ -96,7 +98,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
96
98
  {hasBody && (
97
99
  <div>
98
100
  <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
99
- Description
101
+ {t("discussion.description")}
100
102
  </div>
101
103
  <div className="text-sm">
102
104
  <Markdown>{data.body}</Markdown>
@@ -106,14 +108,14 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
106
108
 
107
109
  {!hasBody && !hasComments && (
108
110
  <div className="text-center py-12">
109
- <p className="text-sm text-muted-foreground/40">No description or comments</p>
111
+ <p className="text-sm text-muted-foreground/40">{t("discussion.noContent")}</p>
110
112
  </div>
111
113
  )}
112
114
 
113
115
  {hasComments && (
114
116
  <div>
115
117
  <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
116
- Comments
118
+ {t("discussion.comments")}
117
119
  <span className="ml-1.5 text-muted-foreground/25">{data.comments.length}</span>
118
120
  </div>
119
121
  <div className="space-y-0">
@@ -134,7 +136,7 @@ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
134
136
  )}
135
137
  <span className="text-sm font-medium">{comment.author}</span>
136
138
  <span className="text-xs text-muted-foreground/40">
137
- {timeAgo(comment.created_at)}
139
+ {timeAgo(comment.created_at, t)}
138
140
  </span>
139
141
  <a
140
142
  href={comment.html_url}
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from "react";
2
2
  import { ChevronRight, Plus, Pencil, Trash2, ArrowRight, FolderTree, Layers, ArrowDownWideNarrow } from "lucide-react";
3
3
  import type { FileChange, FileGroup, FileStatus } from "../../../types/output.ts";
4
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
4
5
 
5
6
  type ViewMode = "tree" | "group" | "changes";
6
7
 
@@ -212,6 +213,7 @@ function GroupView({
212
213
  onFileSelect,
213
214
  expanded,
214
215
  onToggleExpand,
216
+ ungroupedLabel,
215
217
  }: {
216
218
  files: FileChange[];
217
219
  groups: FileGroup[];
@@ -219,6 +221,7 @@ function GroupView({
219
221
  onFileSelect?: (path: string) => void;
220
222
  expanded: Set<string>;
221
223
  onToggleExpand: (e: React.MouseEvent, path: string) => void;
224
+ ungroupedLabel: string;
222
225
  }) {
223
226
  const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set(groups.map((g) => g.name)));
224
227
 
@@ -253,7 +256,7 @@ function GroupView({
253
256
  {[...filesByGroup.entries()].map(([groupName, groupFiles]) => {
254
257
  const isOpen = openGroups.has(groupName);
255
258
  const meta = groupMeta.get(groupName);
256
- const displayName = groupName === "_ungrouped" ? "Ungrouped" : groupName;
259
+ const displayName = groupName === "_ungrouped" ? ungroupedLabel : groupName;
257
260
  const totalAdd = groupFiles.reduce((s, f) => s + f.additions, 0);
258
261
  const totalDel = groupFiles.reduce((s, f) => s + f.deletions, 0);
259
262
 
@@ -347,10 +350,10 @@ function ChangesView({
347
350
  );
348
351
  }
349
352
 
350
- const VIEW_MODES: { value: ViewMode; icon: typeof FolderTree; label: string }[] = [
351
- { value: "tree", icon: FolderTree, label: "Tree" },
352
- { value: "group", icon: Layers, label: "Groups" },
353
- { value: "changes", icon: ArrowDownWideNarrow, label: "Changes" },
353
+ const VIEW_MODE_KEYS: { value: ViewMode; icon: typeof FolderTree; labelKey: TranslationKey }[] = [
354
+ { value: "tree", icon: FolderTree, labelKey: "files.tree" },
355
+ { value: "group", icon: Layers, labelKey: "files.groups" },
356
+ { value: "changes", icon: ArrowDownWideNarrow, labelKey: "files.changes" },
354
357
  ];
355
358
 
356
359
  export function FilesPanel({
@@ -376,6 +379,7 @@ export function FilesPanel({
376
379
  }
377
380
  return dirs;
378
381
  });
382
+ const { t } = useI18n();
379
383
 
380
384
  const tree = useMemo(() => collapseTree(buildTree(files)), [files]);
381
385
 
@@ -403,17 +407,17 @@ export function FilesPanel({
403
407
  <div className="pt-5">
404
408
  <div className="flex items-center justify-between mb-3">
405
409
  <div className="flex items-center gap-2">
406
- <span className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider">{files.length} files</span>
410
+ <span className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider">{t("files.nFiles", { n: files.length })}</span>
407
411
  <span className="text-xs tabular-nums text-green-600 dark:text-green-400">+{totalAdd}</span>
408
412
  <span className="text-xs tabular-nums text-red-600 dark:text-red-400">-{totalDel}</span>
409
413
  </div>
410
414
  <div className="flex items-center gap-px rounded-md border p-0.5">
411
- {VIEW_MODES.map(({ value, icon: ModeIcon, label }) => (
415
+ {VIEW_MODE_KEYS.map(({ value, icon: ModeIcon, labelKey }) => (
412
416
  <button
413
417
  key={value}
414
418
  type="button"
415
419
  onClick={() => setViewMode(value)}
416
- title={label}
420
+ title={t(labelKey)}
417
421
  className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
418
422
  viewMode === value
419
423
  ? "bg-accent text-foreground font-medium"
@@ -421,7 +425,7 @@ export function FilesPanel({
421
425
  }`}
422
426
  >
423
427
  <ModeIcon className="h-3 w-3" />
424
- <span className="hidden sm:inline">{label}</span>
428
+ <span className="hidden sm:inline">{t(labelKey)}</span>
425
429
  </button>
426
430
  ))}
427
431
  </div>
@@ -449,6 +453,7 @@ export function FilesPanel({
449
453
  onFileSelect={onFileSelect}
450
454
  expanded={expanded}
451
455
  onToggleExpand={toggleExpand}
456
+ ungroupedLabel={t("files.ungrouped")}
452
457
  />
453
458
  )}
454
459
 
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import type { FileGroup } from "../../../types/output.ts";
4
+ import { useI18n } from "../lib/i18n/index.ts";
4
5
 
5
6
  const TYPE_DOT: Record<string, string> = {
6
7
  feature: "bg-blue-500",
@@ -14,6 +15,7 @@ const TYPE_DOT: Record<string, string> = {
14
15
 
15
16
  export function GroupsPanel({ groups }: { groups: FileGroup[] }) {
16
17
  const [expanded, setExpanded] = useState<Set<number>>(new Set([0]));
18
+ const { t } = useI18n();
17
19
 
18
20
  function toggle(idx: number) {
19
21
  setExpanded((s) => {
@@ -26,7 +28,7 @@ export function GroupsPanel({ groups }: { groups: FileGroup[] }) {
26
28
  return (
27
29
  <div className="pt-5">
28
30
  <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
29
- {groups.length} groups
31
+ {t("groups.nGroups", { n: groups.length })}
30
32
  </div>
31
33
  <div className="space-y-px">
32
34
  {groups.map((group, i) => {