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
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { Loader2, Presentation, RefreshCw, Download, AlertCircle, ChevronLeft, ChevronRight } from "lucide-react";
3
3
  import type { NewprOutput, SlideDeck } from "../../../types/output.ts";
4
4
  import { sendNotification } from "../lib/notify.ts";
5
+ import { useI18n } from "../lib/i18n/index.ts";
5
6
 
6
7
  export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?: string | null }) {
7
8
  const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
@@ -11,6 +12,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
11
12
  const [progressDetail, setProgressDetail] = useState<{ current: number; total: number } | null>(null);
12
13
  const [currentSlide, setCurrentSlide] = useState(0);
13
14
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
15
+ const { t } = useI18n();
14
16
 
15
17
  const stopPolling = useCallback(() => {
16
18
  if (pollRef.current) {
@@ -53,11 +55,11 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
53
55
  if (partial?.slides?.length) {
54
56
  setDeck(partial);
55
57
  setState("done");
56
- sendNotification("Slides ready", `${partial.slides.length} slides generated`);
58
+ sendNotification(t("slides.slidesReady"), t("slides.nSlidesGenerated", { n: partial.slides.length }));
57
59
  }
58
60
  } else if (job.status === "error") {
59
61
  stopPolling();
60
- setError(job.message ?? "Generation failed");
62
+ setError(job.message ?? t("slides.failed"));
61
63
  if (partial?.slides?.length) {
62
64
  setDeck(partial);
63
65
  setState("done");
@@ -67,13 +69,13 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
67
69
  }
68
70
  } catch {}
69
71
  }, 1000);
70
- }, [sessionId, stopPolling]);
72
+ }, [sessionId, stopPolling, t]);
71
73
 
72
74
  const generate = useCallback(async (resume = false) => {
73
75
  if (!sessionId) return;
74
76
  setState("loading");
75
77
  setError(null);
76
- setProgress(resume ? "Resuming failed slides..." : "Starting...");
78
+ setProgress(resume ? t("slides.resuming") : t("slides.starting"));
77
79
  setProgressDetail(null);
78
80
 
79
81
  try {
@@ -91,7 +93,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
91
93
  setError(err instanceof Error ? err.message : String(err));
92
94
  setState("error");
93
95
  }
94
- }, [sessionId, startPolling]);
96
+ }, [sessionId, startPolling, t]);
95
97
 
96
98
  useEffect(() => {
97
99
  if (!sessionId || state !== "idle") return;
@@ -122,9 +124,9 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
122
124
  <div className="pt-8 flex flex-col items-center">
123
125
  <div className="w-full max-w-sm space-y-6">
124
126
  <div className="space-y-2">
125
- <h3 className="text-xs font-medium">Slide Deck</h3>
127
+ <h3 className="text-xs font-medium">{t("slides.title")}</h3>
126
128
  <p className="text-[11px] text-muted-foreground/60 leading-relaxed">
127
- Generate a presentation that explains this PR to your team. The number of slides is automatically determined based on PR complexity.
129
+ {t("slides.description")}
128
130
  </p>
129
131
  </div>
130
132
  <button
@@ -133,7 +135,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
133
135
  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"
134
136
  >
135
137
  <Presentation className="h-3.5 w-3.5" />
136
- Generate Slides
138
+ {t("slides.generateSlides")}
137
139
  </button>
138
140
  </div>
139
141
  </div>
@@ -183,7 +185,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
183
185
  </div>
184
186
  )}
185
187
  {partialSlides.length === 0 && !progressDetail && (
186
- <p className="text-[10px] text-muted-foreground/30 text-center">This may take a few minutes</p>
188
+ <p className="text-[10px] text-muted-foreground/30 text-center">{t("slides.takesMinutes")}</p>
187
189
  )}
188
190
  </div>
189
191
  );
@@ -196,7 +198,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
196
198
  <div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 flex items-start gap-2.5">
197
199
  <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0 mt-0.5" />
198
200
  <div className="space-y-1 min-w-0">
199
- <p className="text-xs text-destructive font-medium">Generation failed</p>
201
+ <p className="text-xs text-destructive font-medium">{t("slides.failed")}</p>
200
202
  <p className="text-[11px] text-destructive/70 break-words">{error}</p>
201
203
  </div>
202
204
  </div>
@@ -206,7 +208,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
206
208
  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"
207
209
  >
208
210
  <RefreshCw className="h-3 w-3" />
209
- Try again
211
+ {t("common.tryAgain")}
210
212
  </button>
211
213
  </div>
212
214
  </div>
@@ -225,7 +227,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
225
227
  <div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
226
228
  <AlertCircle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
227
229
  <span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
228
- {deck.failedIndices!.length} slide{deck.failedIndices!.length > 1 ? "s" : ""} failed to generate
230
+ {t("slides.slidesFailed", { n: deck.failedIndices!.length })}
229
231
  </span>
230
232
  <button
231
233
  type="button"
@@ -233,7 +235,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
233
235
  className="flex items-center gap-1 text-[11px] font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 shrink-0 transition-colors"
234
236
  >
235
237
  <RefreshCw className="h-3 w-3" />
236
- Retry failed
238
+ {t("slides.retryFailed")}
237
239
  </button>
238
240
  </div>
239
241
  )}
@@ -280,7 +282,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
280
282
  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"
281
283
  >
282
284
  <Download className="h-3 w-3" />
283
- Download
285
+ {t("common.download")}
284
286
  </button>
285
287
  <button
286
288
  type="button"
@@ -288,7 +290,7 @@ export function SlidesPanel({ data, sessionId }: { data: NewprOutput; sessionId?
288
290
  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"
289
291
  >
290
292
  <RefreshCw className="h-3 w-3" />
291
- Regenerate
293
+ {t("common.regenerate")}
292
294
  </button>
293
295
  </div>
294
296
  </div>
@@ -4,15 +4,16 @@ import { useStack } from "../hooks/useStack.ts";
4
4
  import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
5
5
  import { StackDagView } from "../components/StackDagView.tsx";
6
6
  import { StackWarnings } from "../components/StackWarnings.tsx";
7
+ import { useI18n, type TranslationKey } from "../lib/i18n/index.ts";
7
8
 
8
9
  type StackPhase = "idle" | "partitioning" | "planning" | "executing" | "publishing" | "done" | "error";
9
10
 
10
- const PIPELINE_STEPS = [
11
- { phase: "partitioning" as const, label: "Partition", description: "Assigning files to groups" },
12
- { phase: "planning" as const, label: "Plan", description: "Building stack plan" },
13
- { phase: "executing" as const, label: "Execute", description: "Creating commits" },
14
- { phase: "publishing" as const, label: "Publish", description: "Pushing branches and creating draft PRs" },
15
- ] as const;
11
+ const PIPELINE_STEP_KEYS: Array<{ phase: string; labelKey: TranslationKey; descKey: TranslationKey }> = [
12
+ { phase: "partitioning", labelKey: "stack.partition", descKey: "stack.partitionDesc" },
13
+ { phase: "planning", labelKey: "stack.plan", descKey: "stack.planDesc" },
14
+ { phase: "executing", labelKey: "stack.execute", descKey: "stack.executeDesc" },
15
+ { phase: "publishing", labelKey: "stack.publish", descKey: "stack.publishDesc" },
16
+ ];
16
17
 
17
18
  function getStepState(stepPhase: string, currentPhase: StackPhase, isDone: boolean) {
18
19
  const order = ["partitioning", "planning", "executing", "publishing"];
@@ -26,12 +27,13 @@ function getStepState(stepPhase: string, currentPhase: StackPhase, isDone: boole
26
27
 
27
28
  function PipelineTimeline({ phase }: { phase: StackPhase }) {
28
29
  const isDone = phase === "done";
30
+ const { t } = useI18n();
29
31
 
30
32
  return (
31
33
  <div className="relative flex flex-col gap-0 py-1">
32
- {PIPELINE_STEPS.map((step, i) => {
34
+ {PIPELINE_STEP_KEYS.map((step, i) => {
33
35
  const state = getStepState(step.phase, phase, isDone);
34
- const isLast = i === PIPELINE_STEPS.length - 1;
36
+ const isLast = i === PIPELINE_STEP_KEYS.length - 1;
35
37
 
36
38
  return (
37
39
  <div key={step.phase} className="relative flex items-start gap-3">
@@ -57,10 +59,10 @@ function PipelineTimeline({ phase }: { phase: StackPhase }) {
57
59
  : state === "active" ? "text-foreground"
58
60
  : "text-muted-foreground/40"
59
61
  }`}>
60
- {step.label}
62
+ {t(step.labelKey)}
61
63
  </span>
62
64
  {state === "active" && (
63
- <p className="text-[11px] text-muted-foreground/50 mt-0.5">{step.description}</p>
65
+ <p className="text-[11px] text-muted-foreground/50 mt-0.5">{t(step.descKey)}</p>
64
66
  )}
65
67
  </div>
66
68
  </div>
@@ -75,6 +77,7 @@ function EnvVarsInput({ envVars, setEnvVars }: { envVars: Record<string, string>
75
77
  const entries = Object.entries(envVars);
76
78
  return entries.length > 0 ? entries.map(([key, value]) => ({ key, value })) : [];
77
79
  });
80
+ const { t } = useI18n();
78
81
 
79
82
  useEffect(() => {
80
83
  const record: Record<string, string> = {};
@@ -97,14 +100,14 @@ function EnvVarsInput({ envVars, setEnvVars }: { envVars: Record<string, string>
97
100
  <details className="mb-5 group">
98
101
  <summary className="cursor-pointer list-none flex items-center gap-2 text-[11px] text-muted-foreground/40 hover:text-muted-foreground/60 transition-colors select-none">
99
102
  <KeyRound className="h-3 w-3" />
100
- <span>Environment Variables</span>
103
+ <span>{t("stack.envVars")}</span>
101
104
  {rows.length > 0 && (
102
105
  <span className="text-[10px] tabular-nums text-muted-foreground/25">({rows.length})</span>
103
106
  )}
104
107
  </summary>
105
108
  <div className="mt-2.5 space-y-2">
106
109
  <p className="text-[10px] text-muted-foreground/30 leading-relaxed">
107
- Set env vars for quality gate scripts (e.g. NPM_TOKEN, CI tokens)
110
+ {t("stack.envVarsDesc")}
108
111
  </p>
109
112
  {rows.map((row, idx) => (
110
113
  <div key={idx} className="flex items-center gap-1.5">
@@ -137,7 +140,7 @@ function EnvVarsInput({ envVars, setEnvVars }: { envVars: Record<string, string>
137
140
  className="flex items-center gap-1.5 text-[10px] text-muted-foreground/30 hover:text-muted-foreground/60 transition-colors"
138
141
  >
139
142
  <Plus className="h-3 w-3" />
140
- Add variable
143
+ {t("stack.addVariable")}
141
144
  </button>
142
145
  </div>
143
146
  </details>
@@ -156,12 +159,14 @@ interface QualityGateResultData {
156
159
  }
157
160
 
158
161
  function QualityGateResults({ result }: { result: QualityGateResultData }) {
162
+ const { t } = useI18n();
163
+
159
164
  if (!result.ran && result.skippedReason) {
160
165
  return (
161
166
  <div className="flex items-center gap-2.5 rounded-lg bg-foreground/[0.03] px-3.5 py-2.5">
162
167
  <Circle className="h-3.5 w-3.5 text-muted-foreground/30 shrink-0" />
163
168
  <span className="text-[11px] text-muted-foreground/40">
164
- Quality gate skipped: {result.skippedReason}
169
+ {t("stack.qualityGateSkipped", { reason: result.skippedReason })}
165
170
  </span>
166
171
  </div>
167
172
  );
@@ -188,9 +193,9 @@ function QualityGateResults({ result }: { result: QualityGateResultData }) {
188
193
  ? "text-green-700/70 dark:text-green-300/70"
189
194
  : "text-yellow-700/70 dark:text-yellow-300/70"
190
195
  }`}>
191
- Quality gate: {allPassed
192
- ? `all ${totalGroups} groups passed`
193
- : `${passedGroups}/${totalGroups} groups passed`
196
+ {t("stack.qualityGate")}: {allPassed
197
+ ? t("stack.qualityGateAllPassed", { n: totalGroups })
198
+ : t("stack.qualityGatePartial", { passed: passedGroups, total: totalGroups })
194
199
  }
195
200
  </span>
196
201
  </div>
@@ -198,7 +203,7 @@ function QualityGateResults({ result }: { result: QualityGateResultData }) {
198
203
  {failedGroups.length > 0 && (
199
204
  <details className="rounded-lg border border-yellow-500/20 bg-yellow-500/[0.03]">
200
205
  <summary className="cursor-pointer list-none px-3.5 py-2.5 text-[11px] text-yellow-700/70 dark:text-yellow-300/70 hover:bg-yellow-500/[0.04] transition-colors select-none">
201
- {failedGroups.length} group(s) with warnings (non-blocking)
206
+ {t("stack.groupsWithWarnings", { n: failedGroups.length })}
202
207
  </summary>
203
208
  <div className="px-3.5 pb-3 space-y-2.5">
204
209
  {failedGroups.map((group) => (
@@ -233,6 +238,7 @@ interface StackPanelProps {
233
238
 
234
239
  export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
235
240
  const stack = useStack(sessionId, { onTrackAnalysis });
241
+ const { t } = useI18n();
236
242
  const publishedCount = stack.publishResult?.prs.length ?? 0;
237
243
  const pushedCount = stack.publishResult?.branches.filter((b) => b.pushed).length ?? 0;
238
244
  const publishFailures = stack.publishResult
@@ -255,17 +261,17 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
255
261
  <Layers className="h-4 w-4 text-foreground/50" />
256
262
  </div>
257
263
  <div>
258
- <h3 className="text-[13px] font-semibold text-foreground">PR Stacking</h3>
259
- <p className="text-[11px] text-muted-foreground/50">Split into focused, reviewable PRs</p>
264
+ <h3 className="text-[13px] font-semibold text-foreground">{t("stack.title")}</h3>
265
+ <p className="text-[11px] text-muted-foreground/50">{t("stack.subtitle")}</p>
260
266
  </div>
261
267
  </div>
262
268
 
263
269
  <p className="text-[11px] text-muted-foreground/40 leading-[1.6] mb-5">
264
- Automatically split this PR into a stack of smaller draft PRs based on the analysis groups. Each group becomes its own PR with proper dependency ordering.
270
+ {t("stack.description")}
265
271
  </p>
266
272
 
267
273
  <div className="flex items-center gap-3 mb-5">
268
- <span className="text-[11px] text-muted-foreground/50">Max PRs</span>
274
+ <span className="text-[11px] text-muted-foreground/50">{t("stack.maxPrs")}</span>
269
275
  <div className="flex items-center">
270
276
  <button
271
277
  type="button"
@@ -300,7 +306,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
300
306
  className="w-full flex items-center justify-center gap-2 rounded-lg bg-foreground text-background px-4 py-2.5 text-[12px] font-medium hover:bg-foreground/90 transition-colors"
301
307
  >
302
308
  <Play className="h-3.5 w-3.5" />
303
- Start Stacking
309
+ {t("stack.startStacking")}
304
310
  </button>
305
311
  </div>
306
312
  );
@@ -313,12 +319,12 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
313
319
  <div className="flex items-center gap-2">
314
320
  <div className="flex items-center gap-2 flex-1 min-w-0">
315
321
  <Layers className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />
316
- <span className="text-[12px] font-semibold text-foreground">PR Stacking</span>
322
+ <span className="text-[12px] font-semibold text-foreground">{t("stack.title")}</span>
317
323
  {stack.phase === "done" && !stack.publishResult && (
318
- <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-foreground/[0.06] text-muted-foreground/60">Ready</span>
324
+ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-foreground/[0.06] text-muted-foreground/60">{t("stack.ready")}</span>
319
325
  )}
320
326
  {stack.publishResult && (
321
- <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">Published</span>
327
+ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">{t("stack.published")}</span>
322
328
  )}
323
329
  </div>
324
330
  {stack.phase === "done" && (
@@ -338,7 +344,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
338
344
  {stack.phase === "publishing" && (
339
345
  <div className="rounded-lg bg-blue-500/[0.05] px-3.5 py-2.5">
340
346
  <p className="text-[10px] text-blue-700/80 dark:text-blue-300/80 leading-relaxed">
341
- Uploading stack branches and opening draft PRs. Please wait until publish results appear below.
347
+ {t("stack.publishingInfo")}
342
348
  </p>
343
349
  </div>
344
350
  )}
@@ -362,7 +368,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
362
368
  className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
363
369
  >
364
370
  <RotateCcw className="h-3 w-3" />
365
- Try again
371
+ {t("common.tryAgain")}
366
372
  </button>
367
373
  </div>
368
374
  )}
@@ -379,11 +385,11 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
379
385
  <div className="space-y-1">
380
386
  <div className="flex items-center justify-between mb-2">
381
387
  <span className="text-[11px] font-medium text-muted-foreground/40">
382
- {stack.plan.groups.length} PRs
388
+ {t("stack.nPrs", { n: stack.plan.groups.length })}
383
389
  </span>
384
390
  {stack.plan.groups.length > 0 && (
385
391
  <span className="text-[10px] text-muted-foreground/25 tabular-nums">
386
- {stack.plan.groups.reduce((sum, g) => sum + g.files.length, 0)} files total
392
+ {t("stack.nFilesTotal", { n: stack.plan.groups.reduce((sum, g) => sum + g.files.length, 0) })}
387
393
  </span>
388
394
  )}
389
395
  </div>
@@ -412,8 +418,8 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
412
418
  : "text-red-600/80 dark:text-red-400/80"
413
419
  }`}>
414
420
  {stack.verifyResult.verified
415
- ? "Tree equivalence verified"
416
- : `Verification failed: ${stack.verifyResult.errors.join(", ")}`
421
+ ? t("stack.treeEquivalenceVerified")
422
+ : t("stack.verificationFailed", { errors: stack.verifyResult.errors.join(", ") })
417
423
  }
418
424
  </span>
419
425
  </div>
@@ -432,7 +438,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
432
438
  <div className="flex items-center justify-between px-1">
433
439
  <div className="flex items-center gap-2">
434
440
  <FileText className="h-3.5 w-3.5 text-muted-foreground/60" />
435
- <span className="text-[11px] font-medium text-foreground/80">Description Preview</span>
441
+ <span className="text-[11px] font-medium text-foreground/80">{t("stack.descriptionPreview")}</span>
436
442
  </div>
437
443
  <button
438
444
  type="button"
@@ -440,7 +446,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
440
446
  className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/45 hover:text-foreground/70 transition-colors"
441
447
  >
442
448
  <RefreshCw className="h-3 w-3" />
443
- Refresh
449
+ {t("common.refresh")}
444
450
  </button>
445
451
  </div>
446
452
 
@@ -451,7 +457,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
451
457
  {stack.publishPreviewLoading && (
452
458
  <div className="flex items-center gap-2 px-2.5 py-2 text-[10px] text-muted-foreground/45">
453
459
  <Loader2 className="h-3 w-3 animate-spin" />
454
- Preparing preview bodies...
460
+ {t("stack.preparingPreview")}
455
461
  </div>
456
462
  )}
457
463
 
@@ -495,7 +501,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
495
501
  className="w-full flex items-center justify-center gap-2 rounded-lg bg-foreground text-background px-4 py-2.5 text-[12px] font-medium hover:bg-foreground/90 transition-colors"
496
502
  >
497
503
  <Upload className="h-3.5 w-3.5" />
498
- Publish as Draft PRs
504
+ {t("stack.publishAsDraft")}
499
505
  </button>
500
506
  )}
501
507
 
@@ -504,35 +510,35 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
504
510
  <div className="flex items-center justify-between px-1">
505
511
  <div className="flex items-center gap-2">
506
512
  <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70" />
507
- <span className="text-[11px] font-medium text-foreground/80">Draft Publish Results</span>
513
+ <span className="text-[11px] font-medium text-foreground/80">{t("stack.draftPublishResults")}</span>
508
514
  </div>
509
- <span className="text-[10px] text-muted-foreground/35 tabular-nums">{publishedCount}/{pushedCount} created</span>
515
+ <span className="text-[10px] text-muted-foreground/35 tabular-nums">{t("stack.nOfNCreated", { created: publishedCount, total: pushedCount })}</span>
510
516
  </div>
511
517
 
512
518
  <div className="flex flex-wrap items-center gap-2 px-1">
513
519
  <button
514
520
  type="button"
515
521
  onClick={() => {
516
- if (!confirm("Close all stacked draft PRs?")) return;
522
+ if (!confirm(t("stack.confirmCloseAll"))) return;
517
523
  stack.cleanupPublished("close");
518
524
  }}
519
525
  disabled={stack.publishCleanupLoading}
520
526
  className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-border text-[10px] text-foreground/70 hover:bg-accent/30 transition-colors disabled:opacity-50"
521
527
  >
522
528
  {stack.publishCleanupLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <XCircle className="h-3 w-3" />}
523
- Close all
529
+ {t("stack.closeAll")}
524
530
  </button>
525
531
  <button
526
532
  type="button"
527
533
  onClick={() => {
528
- if (!confirm("Close all stacked draft PRs and delete stack branches?")) return;
534
+ if (!confirm(t("stack.confirmCloseDelete"))) return;
529
535
  stack.cleanupPublished("delete");
530
536
  }}
531
537
  disabled={stack.publishCleanupLoading}
532
538
  className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-red-500/25 text-[10px] text-red-600/80 dark:text-red-300/80 hover:bg-red-500/10 transition-colors disabled:opacity-50"
533
539
  >
534
540
  {stack.publishCleanupLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
535
- Close + delete branches
541
+ {t("stack.closeDeleteBranches")}
536
542
  </button>
537
543
  </div>
538
544
 
@@ -582,13 +588,13 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
582
588
  ))}
583
589
  </div>
584
590
  ) : (
585
- <p className="text-[10px] text-muted-foreground/35 px-2.5 py-2">No draft PR URLs were returned.</p>
591
+ <p className="text-[10px] text-muted-foreground/35 px-2.5 py-2">{t("stack.noDraftPrUrls")}</p>
586
592
  )}
587
593
 
588
594
  {publishFailures.length > 0 && (
589
595
  <div className="rounded-md bg-yellow-500/[0.06] px-2.5 py-2 space-y-1">
590
596
  <p className="text-[10px] text-yellow-700/80 dark:text-yellow-300/80">
591
- Some branches were pushed but PR creation did not complete.
597
+ {t("stack.branchesPushedNotCreated")}
592
598
  </p>
593
599
  {publishFailures.map((branch) => (
594
600
  <div key={branch.name} className="text-[10px] font-mono text-yellow-700/70 dark:text-yellow-300/70 truncate">
@@ -1,6 +1,7 @@
1
1
  import type { NewprOutput } from "../../../types/output.ts";
2
2
  import { Markdown } from "../components/Markdown.tsx";
3
3
  import { ChatMessages } from "../components/ChatSection.tsx";
4
+ import { useI18n } from "../lib/i18n/index.ts";
4
5
 
5
6
  const TYPE_DOT: Record<string, string> = {
6
7
  feature: "bg-blue-500",
@@ -22,6 +23,7 @@ export function StoryPanel({
22
23
  onAnchorClick: (kind: "group" | "file" | "line", id: string) => void;
23
24
  }) {
24
25
  const { summary, groups, narrative } = data;
26
+ const { t } = useI18n();
25
27
 
26
28
  return (
27
29
  <div className="pt-5 space-y-6">
@@ -30,11 +32,11 @@ export function StoryPanel({
30
32
 
31
33
  <div className="grid grid-cols-2 gap-x-6 gap-y-3">
32
34
  <div>
33
- <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-1">Scope</div>
35
+ <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-1">{t("story.scope")}</div>
34
36
  <p className="text-sm text-muted-foreground/70 leading-relaxed">{summary.scope}</p>
35
37
  </div>
36
38
  <div>
37
- <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-1">Impact</div>
39
+ <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-1">{t("story.impact")}</div>
38
40
  <p className="text-sm text-muted-foreground/70 leading-relaxed">{summary.impact}</p>
39
41
  </div>
40
42
  </div>
@@ -62,7 +64,7 @@ export function StoryPanel({
62
64
  </div>
63
65
 
64
66
  <div className="border-t pt-5">
65
- <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-4">Walkthrough</div>
67
+ <div className="text-xs font-medium text-muted-foreground/40 uppercase tracking-wider mb-4">{t("story.walkthrough")}</div>
66
68
  <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>
67
69
  {narrative}
68
70
  </Markdown>
@@ -669,27 +669,28 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
669
669
  const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
670
670
  const agents = await detectAgents();
671
671
  const telemetryConsent = await getTelemetryConsent();
672
- return json({
673
- model: config.model,
674
- agent: config.agent ?? null,
675
- language: config.language,
676
- max_files: config.max_files,
677
- timeout: config.timeout,
678
- concurrency: config.concurrency,
679
- has_api_key: !!config.openrouter_api_key,
680
- has_agent_fallback: agents.length > 0,
681
- has_github_token: !!token,
682
- enabled_plugins: enabledPlugins,
683
- available_plugins: pluginList,
684
- telemetry_consent: telemetryConsent,
685
- defaults: {
686
- model: DEFAULT_CONFIG.model,
687
- language: DEFAULT_CONFIG.language,
688
- max_files: DEFAULT_CONFIG.max_files,
689
- timeout: DEFAULT_CONFIG.timeout,
690
- concurrency: DEFAULT_CONFIG.concurrency,
691
- },
692
- });
672
+ return json({
673
+ model: config.model,
674
+ agent: config.agent ?? null,
675
+ language: config.language,
676
+ max_files: config.max_files,
677
+ timeout: config.timeout,
678
+ concurrency: config.concurrency,
679
+ has_api_key: !!config.openrouter_api_key,
680
+ has_agent_fallback: agents.length > 0,
681
+ has_github_token: !!token,
682
+ enabled_plugins: enabledPlugins,
683
+ available_plugins: pluginList,
684
+ telemetry_consent: telemetryConsent,
685
+ custom_prompt: config.custom_prompt ?? "",
686
+ defaults: {
687
+ model: DEFAULT_CONFIG.model,
688
+ language: DEFAULT_CONFIG.language,
689
+ max_files: DEFAULT_CONFIG.max_files,
690
+ timeout: DEFAULT_CONFIG.timeout,
691
+ concurrency: DEFAULT_CONFIG.concurrency,
692
+ },
693
+ });
693
694
  },
694
695
 
695
696
  "PUT /api/config": async (req: Request) => {
@@ -732,6 +733,11 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
732
733
  if ((body as Record<string, unknown>).enabled_plugins !== undefined) {
733
734
  update.enabled_plugins = (body as Record<string, unknown>).enabled_plugins as string[];
734
735
  }
736
+ if ((body as Record<string, unknown>).custom_prompt !== undefined) {
737
+ const val = (body as Record<string, unknown>).custom_prompt as string;
738
+ update.custom_prompt = val || undefined;
739
+ config.custom_prompt = val || undefined;
740
+ }
735
741
 
736
742
  const telemetryConsentVal = (body as Record<string, unknown>).telemetry_consent as string | undefined;
737
743
  if (telemetryConsentVal === "granted" || telemetryConsentVal === "denied") {