newpr 1.0.27 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import type { GithubUser } from "../hooks/useGithubUser.ts";
8
8
  import { SettingsPanel } from "./SettingsPanel.tsx";
9
9
  import { ResizeHandle } from "./ResizeHandle.tsx";
10
10
  import { analytics } from "../lib/analytics.ts";
11
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
11
12
 
12
13
  type Theme = "light" | "dark" | "system";
13
14
 
@@ -28,22 +29,22 @@ const RISK_DOT: Record<string, string> = {
28
29
  critical: "bg-red-600",
29
30
  };
30
31
 
31
- const STATE_LABEL: Record<string, { text: string; class: string }> = {
32
- open: { text: "Open", class: "text-green-600 dark:text-green-400" },
33
- merged: { text: "Merged", class: "text-purple-600 dark:text-purple-400" },
34
- closed: { text: "Closed", class: "text-red-600 dark:text-red-400" },
35
- draft: { text: "Draft", class: "text-neutral-500" },
32
+ const STATE_CLASS: Record<string, string> = {
33
+ open: "text-green-600 dark:text-green-400",
34
+ merged: "text-purple-600 dark:text-purple-400",
35
+ closed: "text-red-600 dark:text-red-400",
36
+ draft: "text-neutral-500",
36
37
  };
37
38
 
38
- function formatTimeAgo(isoDate: string): string {
39
+ function formatTimeAgo(isoDate: string, t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
39
40
  const diff = Date.now() - new Date(isoDate).getTime();
40
41
  const minutes = Math.floor(diff / 60000);
41
- if (minutes < 1) return "just now";
42
- if (minutes < 60) return `${minutes}m`;
42
+ if (minutes < 1) return t("time.justNow");
43
+ if (minutes < 60) return t("time.minutes", { n: minutes });
43
44
  const hours = Math.floor(minutes / 60);
44
- if (hours < 24) return `${hours}h`;
45
+ if (hours < 24) return t("time.hours", { n: hours });
45
46
  const days = Math.floor(hours / 24);
46
- return `${days}d`;
47
+ return t("time.days", { n: days });
47
48
  }
48
49
 
49
50
  interface RepoGroup {
@@ -78,8 +79,13 @@ function SessionList({
78
79
  activeSessionId?: string | null;
79
80
  onSessionSelect: (id: string) => void;
80
81
  }) {
82
+ const { t } = useI18n();
81
83
  const groups = useMemo(() => groupByRepo(sessions), [sessions]);
82
84
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
85
+ const stateLabels: Record<string, string> = {
86
+ open: t("results.open"), merged: t("results.merged"),
87
+ closed: t("results.closed"), draft: t("results.draft"),
88
+ };
83
89
 
84
90
  const toggle = useCallback((repo: string) => {
85
91
  setCollapsed((prev) => {
@@ -92,7 +98,7 @@ function SessionList({
92
98
  if (sessions.length === 0) {
93
99
  return (
94
100
  <div className="flex-1 overflow-y-auto px-2 flex flex-col items-center justify-center text-center gap-2 opacity-40">
95
- <p className="text-[11px] text-muted-foreground">No analyses yet</p>
101
+ <p className="text-[11px] text-muted-foreground">{t("appShell.noAnalysesYet")}</p>
96
102
  </div>
97
103
  );
98
104
  }
@@ -132,14 +138,14 @@ function SessionList({
132
138
  </div>
133
139
  <div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40">
134
140
  <span className="font-mono">#{s.pr_number}</span>
135
- {s.pr_state && STATE_LABEL[s.pr_state] && (
141
+ {s.pr_state && STATE_CLASS[s.pr_state] && (
136
142
  <>
137
143
  <span className="text-muted-foreground/15">·</span>
138
- <span className={STATE_LABEL[s.pr_state]!.class}>{STATE_LABEL[s.pr_state]!.text}</span>
144
+ <span className={STATE_CLASS[s.pr_state]!}>{stateLabels[s.pr_state]}</span>
139
145
  </>
140
146
  )}
141
147
  <span className="text-muted-foreground/15">·</span>
142
- <span>{formatTimeAgo(s.analyzed_at)}</span>
148
+ <span>{formatTimeAgo(s.analyzed_at, t)}</span>
143
149
  </div>
144
150
  </div>
145
151
  </button>
@@ -187,6 +193,7 @@ export function AppShell({
187
193
  onFeaturesChange?: () => void;
188
194
  children: React.ReactNode;
189
195
  }) {
196
+ const { t } = useI18n();
190
197
  const [settingsOpen, setSettingsOpen] = useState(false);
191
198
  const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
192
199
  const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
@@ -251,7 +258,7 @@ export function AppShell({
251
258
  type="button"
252
259
  onClick={onNewAnalysis}
253
260
  className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
254
- title="New analysis"
261
+ title={t("appShell.newAnalysis")}
255
262
  >
256
263
  <Plus className="h-3.5 w-3.5" />
257
264
  </button>
@@ -263,7 +270,7 @@ export function AppShell({
263
270
  <div className="flex items-center gap-2">
264
271
  <Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
265
272
  <span className="text-[11px] text-blue-600 dark:text-blue-400">
266
- Restarting...
273
+ {t("appShell.restarting")}
267
274
  </span>
268
275
  </div>
269
276
  ) : (
@@ -271,7 +278,7 @@ export function AppShell({
271
278
  <div className="flex items-center gap-2 mb-2">
272
279
  <Download className="h-3 w-3 text-blue-500 shrink-0" />
273
280
  <span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
274
- v{update.latest} available
281
+ {t("appShell.versionAvailable", { version: update.latest ?? "" })}
275
282
  </span>
276
283
  </div>
277
284
  <button
@@ -281,9 +288,9 @@ export function AppShell({
281
288
  className="w-full flex items-center justify-center gap-1.5 rounded-md bg-blue-500 hover:bg-blue-600 text-white text-[11px] font-medium py-1.5 transition-colors disabled:opacity-50"
282
289
  >
283
290
  {update.updating ? (
284
- <><Loader2 className="h-3 w-3 animate-spin" /> Updating...</>
291
+ <><Loader2 className="h-3 w-3 animate-spin" /> {t("appShell.updating")}</>
285
292
  ) : (
286
- <><Download className="h-3 w-3" /> Update &amp; restart</>
293
+ <><Download className="h-3 w-3" /> {t("appShell.updateAndRestart")}</>
287
294
  )}
288
295
  </button>
289
296
  {update.error && (
@@ -341,7 +348,7 @@ export function AppShell({
341
348
  {chatLoading.map(({ sessionId: sid }) => (
342
349
  <div key={sid} className="flex items-center gap-2 rounded-md px-2.5 py-1.5">
343
350
  <Loader2 className="h-3 w-3 animate-spin text-blue-500/60 shrink-0" />
344
- <span className="text-[11px] text-muted-foreground/50 truncate">Chat responding...</span>
351
+ <span className="text-[11px] text-muted-foreground/50 truncate">{t("appShell.chatResponding")}</span>
345
352
  </div>
346
353
  ))}
347
354
  </div>
@@ -368,7 +375,7 @@ export function AppShell({
368
375
  type="button"
369
376
  onClick={() => onThemeChange(next)}
370
377
  className="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-[11px] text-muted-foreground/50 hover:text-foreground hover:bg-accent/40 transition-colors"
371
- title={`Switch to ${next} mode`}
378
+ title={t("appShell.switchToMode", { mode: next })}
372
379
  >
373
380
  <Icon className="h-3 w-3" />
374
381
  <span className="capitalize">{theme}</span>
@@ -378,7 +385,7 @@ export function AppShell({
378
385
  type="button"
379
386
  onClick={() => { analytics.settingsOpened(); setSettingsOpen(true); }}
380
387
  className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:bg-accent/40 hover:text-foreground transition-colors"
381
- title="Settings"
388
+ title={t("appShell.settings")}
382
389
  >
383
390
  <Settings className="h-3 w-3" />
384
391
  </button>
@@ -5,6 +5,7 @@ import { Markdown } from "./Markdown.tsx";
5
5
  import { TipTapEditor, getTextWithAnchors, type AnchorItem, type CommandItem } from "./TipTapEditor.tsx";
6
6
  import type { useEditor } from "@tiptap/react";
7
7
  import { useChatStore } from "../hooks/useChatStore.ts";
8
+ import { useI18n } from "../lib/i18n/index.ts";
8
9
 
9
10
  export interface ChatState {
10
11
  messages: ChatMessage[];
@@ -39,11 +40,12 @@ function formatDuration(ms: number): string {
39
40
  }
40
41
 
41
42
  function CompletionFooter({ durationMs }: { durationMs: number }) {
43
+ const { t } = useI18n();
42
44
  return (
43
45
  <div className="flex items-center gap-1.5 mt-1.5 animate-in fade-in duration-300">
44
46
  <Check className="h-3 w-3 text-emerald-500/70" />
45
47
  <span className="text-[10px] text-muted-foreground/40">
46
- Done · {formatDuration(durationMs)}
48
+ {t("chat.done")} · {formatDuration(durationMs)}
47
49
  </span>
48
50
  </div>
49
51
  );
@@ -99,6 +101,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
99
101
  onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
100
102
  activeId?: string | null;
101
103
  }) {
104
+ const { t } = useI18n();
102
105
  const hasContent = segments.some((s) => s.type === "text" && s.content);
103
106
 
104
107
  return (
@@ -124,7 +127,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
124
127
  <div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
125
128
  <Loader2 className="h-2.5 w-2.5 animate-spin" />
126
129
  {activeToolName === "thinking" ? (
127
- <span>Thinking…</span>
130
+ <span>{t("chat.thinking")}</span>
128
131
  ) : (
129
132
  <span className="font-mono">{activeToolName}</span>
130
133
  )}
@@ -145,6 +148,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
145
148
 
146
149
  function CompactSummary({ message }: { message: ChatMessage }) {
147
150
  const [expanded, setExpanded] = useState(false);
151
+ const { t } = useI18n();
148
152
  return (
149
153
  <div className="rounded-lg border border-dashed bg-muted/30 px-3 py-2">
150
154
  <button
@@ -154,7 +158,7 @@ function CompactSummary({ message }: { message: ChatMessage }) {
154
158
  >
155
159
  <FoldVertical className="h-3 w-3 text-muted-foreground/40 shrink-0" />
156
160
  <span className="text-[10px] text-muted-foreground/50 flex-1">
157
- {message.compactedCount ? `${message.compactedCount} messages compacted` : "Conversation compacted"}
161
+ {message.compactedCount ? t("chat.messagesCompacted", { count: message.compactedCount }) : t("chat.conversationCompacted")}
158
162
  </span>
159
163
  {expanded ? (
160
164
  <ChevronDown className="h-3 w-3 text-muted-foreground/30 shrink-0" />
@@ -180,6 +184,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
180
184
  const isNearBottomRef = useRef(true);
181
185
  const mainElRef = useRef<HTMLElement | null>(null);
182
186
  const scrollListenerRef = useRef<(() => void) | null>(null);
187
+ const { t } = useI18n();
183
188
 
184
189
  useEffect(() => {
185
190
  if (scrollListenerRef.current) return;
@@ -215,8 +220,8 @@ export function ChatMessages({ onAnchorClick, activeId }: {
215
220
  if (!hasMessages && loaded) {
216
221
  return (
217
222
  <div className="border-t mt-6 pt-6 text-center">
218
- <p className="text-[11px] text-muted-foreground/40">Ask anything about this PR</p>
219
- <p className="text-[10px] text-muted-foreground/20 mt-1">@ to reference files · / for commands</p>
223
+ <p className="text-[11px] text-muted-foreground/40">{t("chat.askAnything")}</p>
224
+ <p className="text-[10px] text-muted-foreground/20 mt-1">{t("chat.refAndCmds")}</p>
220
225
  </div>
221
226
  );
222
227
  }
@@ -227,7 +232,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
227
232
 
228
233
  return (
229
234
  <div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
230
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
235
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{t("chat.title")}</div>
231
236
  {messages.map((msg, i) => {
232
237
  if (msg.isCompactSummary) {
233
238
  return <CompactSummary key={`compact-${i}`} message={msg} />;
@@ -239,7 +244,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
239
244
  divider = (
240
245
  <div className="flex items-center gap-2 py-1">
241
246
  <div className="flex-1 h-px bg-yellow-500/20" />
242
- <span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">Previous analysis</span>
247
+ <span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">{t("chat.previousAnalysis")}</span>
243
248
  <div className="flex-1 h-px bg-yellow-500/20" />
244
249
  </div>
245
250
  );
@@ -294,6 +299,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
294
299
  export function ChatInput() {
295
300
  const ctx = useContext(ChatContext);
296
301
  const editorRef = useRef<ReturnType<typeof useEditor>>(null);
302
+ const { t } = useI18n();
297
303
 
298
304
  const handleSubmit = useCallback(() => {
299
305
  if (!ctx) return;
@@ -304,8 +310,8 @@ export function ChatInput() {
304
310
  }, [ctx]);
305
311
 
306
312
  const chatCommands = useMemo<CommandItem[]>(() => [
307
- { id: "undo", label: "/undo", description: "Remove last exchange" },
308
- ], []);
313
+ { id: "undo", label: t("chat.undoLabel"), description: t("chat.undoDesc") },
314
+ ], [t]);
309
315
 
310
316
  if (!ctx) return null;
311
317
  const { loading } = ctx.state;
@@ -317,7 +323,7 @@ export function ChatInput() {
317
323
  <div className="relative rounded-xl border bg-background px-4 py-2.5 pr-12 focus-within:border-foreground/15 focus-within:shadow-sm transition-all">
318
324
  <TipTapEditor
319
325
  editorRef={editorRef}
320
- placeholder="Ask about this PR..."
326
+ placeholder={t("chat.askAboutPr")}
321
327
  disabled={loading}
322
328
  submitOnEnter
323
329
  onSubmit={handleSubmit}
@@ -340,10 +346,10 @@ export function ChatInput() {
340
346
  </div>
341
347
  <div className="flex items-center justify-between mt-1.5 px-1">
342
348
  <span className="text-[10px] text-muted-foreground/25">
343
- @ to reference · / for commands
349
+ {t("chat.atToRef")}
344
350
  </span>
345
351
  <span className="text-[10px] text-muted-foreground/25">
346
- Enter to send
352
+ {t("chat.enterToSend")}
347
353
  </span>
348
354
  </div>
349
355
  </div>
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { Plus, Pencil, Trash2, ArrowRight, X, Loader2, AlertCircle } from "lucide-react";
3
3
  import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
4
4
  import { DiffViewer } from "./DiffViewer.tsx";
5
+ import { useI18n } from "../lib/i18n/index.ts";
5
6
 
6
7
  export interface DetailTarget {
7
8
  kind: "group" | "file" | "line";
@@ -117,6 +118,7 @@ function FileDetail({
117
118
  const Icon = STATUS_ICON[file.status];
118
119
  const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
119
120
  const scrollContainerRef = useRef<HTMLDivElement>(null);
121
+ const { t } = useI18n();
120
122
 
121
123
  useEffect(() => {
122
124
  if (sessionId && !patch && !loading && !error) {
@@ -146,7 +148,7 @@ function FileDetail({
146
148
  {loading && (
147
149
  <div className="flex items-center justify-center py-16 gap-2">
148
150
  <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
149
- <span className="text-sm text-muted-foreground/50">Loading diff</span>
151
+ <span className="text-sm text-muted-foreground/50">{t("detail.loadingDiff")}</span>
150
152
  </div>
151
153
  )}
152
154
  {error && (
@@ -160,7 +162,7 @@ function FileDetail({
160
162
  onClick={fetchPatch}
161
163
  className="text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
162
164
  >
163
- Retry
165
+ {t("common.retry")}
164
166
  </button>
165
167
  </div>
166
168
  )}
@@ -193,6 +195,8 @@ export function DetailPane({
193
195
  }) {
194
196
  if (!target) return null;
195
197
 
198
+ const { t } = useI18n();
199
+
196
200
  if (target.kind === "group" && target.group) {
197
201
  const g = target.group;
198
202
  const totalAdd = target.files.reduce((s, f) => s + f.additions, 0);
@@ -218,7 +222,7 @@ export function DetailPane({
218
222
 
219
223
  {g.key_changes && g.key_changes.length > 0 && (
220
224
  <div>
221
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">Key Changes</div>
225
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-2">{t("detail.keyChanges")}</div>
222
226
  <ul className="space-y-1.5">
223
227
  {g.key_changes.map((change, i) => (
224
228
  <li key={i} className="flex gap-2 text-[11px] text-muted-foreground/70 leading-relaxed">
@@ -232,14 +236,14 @@ export function DetailPane({
232
236
 
233
237
  {g.risk && (
234
238
  <div>
235
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">Risk</div>
239
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">{t("detail.risk")}</div>
236
240
  <p className="text-[11px] text-muted-foreground/60 leading-relaxed">{g.risk}</p>
237
241
  </div>
238
242
  )}
239
243
 
240
244
  {g.dependencies && g.dependencies.length > 0 && (
241
245
  <div>
242
- <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">Dependencies</div>
246
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-1.5">{t("detail.dependencies")}</div>
243
247
  <div className="flex flex-wrap gap-1.5">
244
248
  {g.dependencies.map((dep) => (
245
249
  <span key={dep} className="text-[10px] px-1.5 py-0.5 rounded-md bg-accent/60 text-muted-foreground/60">{dep}</span>
@@ -250,7 +254,7 @@ export function DetailPane({
250
254
 
251
255
  <div>
252
256
  <div className="flex items-center gap-2 mb-2.5">
253
- <span className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{target.files.length} files</span>
257
+ <span className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{t("detail.nFiles", { n: target.files.length })}</span>
254
258
  <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{totalAdd}</span>
255
259
  <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{totalDel}</span>
256
260
  </div>
@@ -1,58 +1,31 @@
1
1
  import { useState } from "react";
2
2
  import { AlertCircle, RotateCcw, ArrowLeft } from "lucide-react";
3
3
  import { Button } from "../../components/ui/button.tsx";
4
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
4
5
 
5
- function categorizeError(message: string): { title: string; hint: string; retryable: boolean } {
6
+ function categorizeError(message: string): { titleKey: TranslationKey; hintKey: TranslationKey; retryable: boolean } {
6
7
  const lower = message.toLowerCase();
7
8
 
8
9
  if (lower.includes("rate limit") || lower.includes("429")) {
9
- return {
10
- title: "Rate limit reached",
11
- hint: "The API rate limit has been exceeded. Wait a moment before retrying.",
12
- retryable: true,
13
- };
10
+ return { titleKey: "error.rateLimitTitle", hintKey: "error.rateLimitHint", retryable: true };
14
11
  }
15
12
  if (lower.includes("timeout") || lower.includes("timed out")) {
16
- return {
17
- title: "Request timed out",
18
- hint: "The analysis took too long. This can happen with very large PRs.",
19
- retryable: true,
20
- };
13
+ return { titleKey: "error.timeoutTitle", hintKey: "error.timeoutHint", retryable: true };
21
14
  }
22
15
  if (lower.includes("network") || lower.includes("fetch") || lower.includes("econnrefused")) {
23
- return {
24
- title: "Connection failed",
25
- hint: "Could not reach the server. Check your network connection.",
26
- retryable: true,
27
- };
16
+ return { titleKey: "error.networkTitle", hintKey: "error.networkHint", retryable: true };
28
17
  }
29
18
  if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("token")) {
30
- return {
31
- title: "Authentication error",
32
- hint: "Your GitHub token may be expired or invalid. Run `newpr auth` to reconfigure.",
33
- retryable: false,
34
- };
19
+ return { titleKey: "error.authTitle", hintKey: "error.authHint", retryable: false };
35
20
  }
36
21
  if (lower.includes("404") || lower.includes("not found")) {
37
- return {
38
- title: "PR not found",
39
- hint: "The pull request could not be found. Check the URL and make sure you have access.",
40
- retryable: false,
41
- };
22
+ return { titleKey: "error.notFoundTitle", hintKey: "error.notFoundHint", retryable: false };
42
23
  }
43
24
  if (lower.includes("openrouter") || lower.includes("api key")) {
44
- return {
45
- title: "API key error",
46
- hint: "Your OpenRouter API key may be missing or invalid. Set OPENROUTER_API_KEY in your environment.",
47
- retryable: false,
48
- };
25
+ return { titleKey: "error.apiKeyTitle", hintKey: "error.apiKeyHint", retryable: false };
49
26
  }
50
27
 
51
- return {
52
- title: "Analysis failed",
53
- hint: "Something went wrong during the analysis.",
54
- retryable: true,
55
- };
28
+ return { titleKey: "error.defaultTitle", hintKey: "error.defaultHint", retryable: true };
56
29
  }
57
30
 
58
31
  export function ErrorScreen({
@@ -65,7 +38,8 @@ export function ErrorScreen({
65
38
  onBack: () => void;
66
39
  }) {
67
40
  const [retrying, setRetrying] = useState(false);
68
- const { title, hint, retryable } = categorizeError(error);
41
+ const { t } = useI18n();
42
+ const { titleKey, hintKey, retryable } = categorizeError(error);
69
43
 
70
44
  function handleRetry() {
71
45
  if (!onRetry) return;
@@ -81,9 +55,9 @@ export function ErrorScreen({
81
55
  </div>
82
56
 
83
57
  <div className="flex flex-col items-center gap-2 text-center">
84
- <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
58
+ <h2 className="text-lg font-semibold tracking-tight">{t(titleKey)}</h2>
85
59
  <p className="text-base text-muted-foreground leading-relaxed max-w-sm">
86
- {hint}
60
+ {t(hintKey)}
87
61
  </p>
88
62
  </div>
89
63
 
@@ -101,7 +75,7 @@ export function ErrorScreen({
101
75
  size="default"
102
76
  >
103
77
  <RotateCcw className={`mr-2 h-3.5 w-3.5 ${retrying ? "animate-spin" : ""}`} />
104
- {retrying ? "Retrying..." : "Try again"}
78
+ {retrying ? t("common.retrying") : t("common.tryAgain")}
105
79
  </Button>
106
80
  )}
107
81
  <Button
@@ -110,7 +84,7 @@ export function ErrorScreen({
110
84
  size="default"
111
85
  >
112
86
  <ArrowLeft className="mr-2 h-3.5 w-3.5" />
113
- Back
87
+ {t("common.back")}
114
88
  </Button>
115
89
  </div>
116
90
  </div>
@@ -1,15 +1,17 @@
1
1
  import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
2
2
  import { useState } from "react";
3
3
  import type { FeasibilityResult } from "../../../stack/types.ts";
4
+ import { useI18n } from "../lib/i18n/index.ts";
4
5
 
5
6
  export function FeasibilityAlert({ result }: { result: FeasibilityResult }) {
6
7
  const [expanded, setExpanded] = useState(false);
8
+ const { t } = useI18n();
7
9
 
8
10
  if (result.feasible) {
9
11
  return (
10
12
  <div className="flex items-center gap-2 px-3 py-2 rounded-md bg-green-500/[0.04]">
11
13
  <CheckCircle2 className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
12
- <span className="text-[11px] text-green-700/70 dark:text-green-300/70 font-medium">Feasible</span>
14
+ <span className="text-[11px] text-green-700/70 dark:text-green-300/70 font-medium">{t("feasibility.feasible")}</span>
13
15
  {result.ordered_group_ids && (
14
16
  <span className="text-[10px] text-muted-foreground/25 truncate">
15
17
  {result.ordered_group_ids.join(" → ")}
@@ -30,7 +32,7 @@ export function FeasibilityAlert({ result }: { result: FeasibilityResult }) {
30
32
  >
31
33
  <XCircle className="h-3.5 w-3.5 text-red-500/60 shrink-0" />
32
34
  <span className="text-[11px] text-red-600/80 dark:text-red-400/80 font-medium flex-1">
33
- Not feasible — dependency cycle
35
+ {t("feasibility.notFeasible")}
34
36
  </span>
35
37
  {hasCycleDetails && (
36
38
  <ChevronRight className={`h-3 w-3 text-red-500/30 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
@@ -56,7 +58,7 @@ export function FeasibilityAlert({ result }: { result: FeasibilityResult }) {
56
58
 
57
59
  {result.unassigned_paths && result.unassigned_paths.length > 0 && (
58
60
  <p className="text-[10px] text-red-500/40 pl-5.5">
59
- {result.unassigned_paths.length} unassigned file(s)
61
+ {t("feasibility.unassignedFiles", { n: result.unassigned_paths.length })}
60
62
  </p>
61
63
  )}
62
64
  </div>
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
2
2
  import { CornerDownLeft, GitPullRequest, ExternalLink, ChevronUp } from "lucide-react";
3
3
  import type { SessionRecord } from "../../../history/types.ts";
4
4
  import { analytics } from "../lib/analytics.ts";
5
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
5
6
 
6
7
  interface ToolStatus {
7
8
  name: string;
@@ -23,16 +24,16 @@ const RISK_DOT: Record<string, string> = {
23
24
  critical: "bg-red-600",
24
25
  };
25
26
 
26
- function timeAgo(date: string): string {
27
+ function timeAgo(date: string, t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
27
28
  const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
28
- if (s < 60) return "just now";
29
+ if (s < 60) return t("time.justNow");
29
30
  const m = Math.floor(s / 60);
30
- if (m < 60) return `${m}m ago`;
31
+ if (m < 60) return t("time.minutesAgo", { n: m });
31
32
  const h = Math.floor(m / 60);
32
- if (h < 24) return `${h}h ago`;
33
+ if (h < 24) return t("time.hoursAgo", { n: h });
33
34
  const d = Math.floor(h / 24);
34
- if (d < 30) return `${d}d ago`;
35
- return `${Math.floor(d / 30)}mo ago`;
35
+ if (d < 30) return t("time.daysAgo", { n: d });
36
+ return t("time.monthsAgo", { n: Math.floor(d / 30) });
36
37
  }
37
38
 
38
39
  function StatusDot({ ok, optional }: { ok: boolean; optional?: boolean }) {
@@ -42,6 +43,7 @@ function StatusDot({ ok, optional }: { ok: boolean; optional?: boolean }) {
42
43
  }
43
44
 
44
45
  function CompactStatus({ data }: { data: PreflightData }) {
46
+ const { t } = useI18n();
45
47
  const [open, setOpen] = useState(false);
46
48
  const gh = data.github;
47
49
  const allOk = gh.installed && gh.authenticated && data.openrouterKey;
@@ -53,7 +55,7 @@ function CompactStatus({ data }: { data: PreflightData }) {
53
55
  className="flex items-center gap-1.5 hover:opacity-70 transition-opacity"
54
56
  >
55
57
  <StatusDot ok={allOk} />
56
- <span className="text-[10px] font-mono text-muted-foreground/30">status</span>
58
+ <span className="text-[10px] font-mono text-muted-foreground/30">{t("input.status")}</span>
57
59
  <ChevronUp className={`h-2.5 w-2.5 text-muted-foreground/20 transition-transform ${open ? "" : "rotate-180"}`} />
58
60
  </button>
59
61
  {open && (
@@ -94,6 +96,7 @@ export function InputScreen({
94
96
  onSessionSelect?: (id: string) => void;
95
97
  version?: string;
96
98
  }) {
99
+ const { t } = useI18n();
97
100
  const [value, setValue] = useState("");
98
101
  const [focused, setFocused] = useState(false);
99
102
  const [preflight, setPreflight] = useState<PreflightData | null>(null);
@@ -126,9 +129,9 @@ export function InputScreen({
126
129
  </span>
127
130
  )}
128
131
  </div>
129
- <p className="text-base text-muted-foreground/50">
130
- Turn PRs into navigable stories
131
- </p>
132
+ <p className="text-base text-muted-foreground/50">
133
+ {t("input.tagline")}
134
+ </p>
132
135
  </div>
133
136
 
134
137
  <div>
@@ -157,7 +160,7 @@ export function InputScreen({
157
160
  </div>
158
161
  <div className="flex justify-center mt-2.5">
159
162
  <span className="text-[10px] text-muted-foreground/20 font-mono">
160
- ↵ Enter to analyze
163
+ {t("input.enterToAnalyze")}
161
164
  </span>
162
165
  </div>
163
166
  </form>
@@ -168,9 +171,9 @@ export function InputScreen({
168
171
 
169
172
  {recents.length > 0 && (
170
173
  <div className="space-y-3">
171
- <div className="text-[10px] font-medium text-muted-foreground/25 uppercase tracking-[0.15em] text-center">
172
- Recent
173
- </div>
174
+ <div className="text-[10px] font-medium text-muted-foreground/25 uppercase tracking-[0.15em] text-center">
175
+ {t("input.recent")}
176
+ </div>
174
177
  <div className="space-y-px">
175
178
  {recents.map((s) => (
176
179
  <button
@@ -189,7 +192,7 @@ export function InputScreen({
189
192
  <span className="font-mono">{s.repo.split("/").pop()}</span>
190
193
  <span className="font-mono">#{s.pr_number}</span>
191
194
  <span className="text-muted-foreground/15">·</span>
192
- <span>{timeAgo(s.analyzed_at)}</span>
195
+ <span>{timeAgo(s.analyzed_at, t)}</span>
193
196
  </div>
194
197
  </button>
195
198
  ))}
@@ -211,6 +214,7 @@ export function InputScreen({
211
214
  const SIONIC_HERO_BG = "https://www.sionic.ai/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fmain-intro-bg.1455295d.png&w=1920&q=75";
212
215
 
213
216
  function SponsorBanner() {
217
+ const { t } = useI18n();
214
218
  return (
215
219
  <a
216
220
  href="https://www.sionic.ai"
@@ -234,11 +238,11 @@ function SponsorBanner() {
234
238
  />
235
239
  <div className="h-3 w-px bg-white/15 shrink-0" />
236
240
  <span className="text-[10px] text-white/45 truncate">
237
- The Power of AI for Every Business
241
+ {t("input.sponsorTagline")}
238
242
  </span>
239
243
  </div>
240
244
  <div className="relative flex items-center gap-1.5 shrink-0">
241
- <span className="text-[8px] text-white/20 uppercase tracking-widest">Ad</span>
245
+ <span className="text-[8px] text-white/20 uppercase tracking-widest">{t("input.ad")}</span>
242
246
  <ExternalLink className="h-2.5 w-2.5 text-white/15 group-hover:text-white/40 transition-colors" />
243
247
  </div>
244
248
  </a>