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.
- package/package.json +1 -1
- package/src/analyzer/pipeline.ts +1 -0
- package/src/config/index.ts +1 -0
- package/src/config/store.ts +1 -0
- package/src/llm/prompts.ts +10 -4
- package/src/types/config.ts +1 -0
- package/src/web/client/components/AppShell.tsx +29 -22
- package/src/web/client/components/ChatSection.tsx +18 -12
- package/src/web/client/components/DetailPane.tsx +10 -6
- package/src/web/client/components/ErrorScreen.tsx +15 -41
- package/src/web/client/components/FeasibilityAlert.tsx +5 -3
- package/src/web/client/components/InputScreen.tsx +21 -17
- package/src/web/client/components/LoadingTimeline.tsx +22 -17
- package/src/web/client/components/ResultsScreen.tsx +31 -20
- package/src/web/client/components/ReviewModal.tsx +23 -15
- package/src/web/client/components/SettingsPanel.tsx +100 -25
- package/src/web/client/lib/i18n/context.tsx +76 -0
- package/src/web/client/lib/i18n/en.ts +276 -0
- package/src/web/client/lib/i18n/index.ts +3 -0
- package/src/web/client/lib/i18n/ko.ts +274 -0
- package/src/web/client/main.tsx +4 -1
- package/src/web/client/panels/CartoonPanel.tsx +12 -10
- package/src/web/client/panels/DiscussionPanel.tsx +14 -12
- package/src/web/client/panels/FilesPanel.tsx +14 -9
- package/src/web/client/panels/GroupsPanel.tsx +3 -1
- package/src/web/client/panels/SlidesPanel.tsx +17 -15
- package/src/web/client/panels/StackPanel.tsx +50 -44
- package/src/web/client/panels/StoryPanel.tsx +5 -3
- package/src/web/server/routes.ts +27 -21
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -236,6 +236,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
236
236
|
language: config.language,
|
|
237
237
|
prBody: prData.body,
|
|
238
238
|
discussion: prComments.map((c) => ({ author: c.author, body: c.body })),
|
|
239
|
+
customPrompt: config.custom_prompt,
|
|
239
240
|
};
|
|
240
241
|
const enrichedTag = exploration ? " + codebase context" : "";
|
|
241
242
|
|
package/src/config/index.ts
CHANGED
package/src/config/store.ts
CHANGED
package/src/llm/prompts.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface PromptContext {
|
|
|
19
19
|
language?: string;
|
|
20
20
|
prBody?: string;
|
|
21
21
|
discussion?: Array<{ author: string; body: string }>;
|
|
22
|
+
customPrompt?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
function langDirective(lang?: string): string {
|
|
@@ -26,6 +27,11 @@ function langDirective(lang?: string): string {
|
|
|
26
27
|
return `\nCRITICAL LANGUAGE RULE: ALL text values in your response MUST be written in ${lang}. This includes every summary, description, name, purpose, scope, and impact field. JSON keys stay in English, but ALL string values MUST be in ${lang}. Do NOT use English for any descriptive text.`;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function customPromptDirective(customPrompt?: string): string {
|
|
31
|
+
if (!customPrompt?.trim()) return "";
|
|
32
|
+
return `\n\nADDITIONAL USER INSTRUCTIONS:\n${customPrompt.trim()}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
function formatDiscussion(ctx?: PromptContext): string {
|
|
30
36
|
const parts: string[] = [];
|
|
31
37
|
if (ctx?.prBody?.trim()) {
|
|
@@ -68,7 +74,7 @@ export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext)
|
|
|
68
74
|
Use the commit history and PR discussion to understand the intent behind each change — why the change was made, not just what changed.
|
|
69
75
|
Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
|
|
70
76
|
The "path" value must be the exact file path. The "summary" value is a human-readable description.
|
|
71
|
-
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
77
|
+
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}${customPromptDirective(ctx?.customPrompt)}`,
|
|
72
78
|
user: `${fileList}${commitCtx}${discussionCtx}`,
|
|
73
79
|
};
|
|
74
80
|
}
|
|
@@ -97,7 +103,7 @@ A file MAY appear in multiple groups if it serves multiple purposes.
|
|
|
97
103
|
Use the commit history and PR discussion to understand which changes belong together logically.
|
|
98
104
|
Respond ONLY with a JSON array. Each element: {"name": "...", "type": "...", "description": "...", "files": [...], "key_changes": [...], "risk": "...", "dependencies": [...]}.
|
|
99
105
|
The "type" value must be one of the English keywords listed above. File paths stay as-is.
|
|
100
|
-
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
106
|
+
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}${customPromptDirective(ctx?.customPrompt)}`,
|
|
101
107
|
user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
102
108
|
};
|
|
103
109
|
}
|
|
@@ -122,7 +128,7 @@ export function buildOverallSummaryPrompt(
|
|
|
122
128
|
Use the commit history and PR discussion to understand the development progression and intent. The PR description and reviewer comments provide essential context about why changes were made.
|
|
123
129
|
Respond ONLY with a JSON object: {"purpose": "why this PR exists (1-2 sentences)", "scope": "what areas of code are affected", "impact": "what is the impact of these changes", "risk_level": "low|medium|high"}.
|
|
124
130
|
The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
|
|
125
|
-
No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}`,
|
|
131
|
+
No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}${customPromptDirective(ctx?.customPrompt)}`,
|
|
126
132
|
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
127
133
|
};
|
|
128
134
|
}
|
|
@@ -251,7 +257,7 @@ BAD examples:
|
|
|
251
257
|
- Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" → MUST have (text) after it
|
|
252
258
|
- Low density paragraph: A paragraph with only 1 line anchor and 4+ sentences of plain text → MUST add more anchors
|
|
253
259
|
|
|
254
|
-
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and anchor tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}`,
|
|
260
|
+
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and anchor tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}${customPromptDirective(ctx?.customPrompt)}`,
|
|
255
261
|
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
|
|
256
262
|
};
|
|
257
263
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
|
32
|
-
open:
|
|
33
|
-
merged:
|
|
34
|
-
closed:
|
|
35
|
-
draft:
|
|
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 "
|
|
42
|
-
if (minutes < 60) return
|
|
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
|
|
45
|
+
if (hours < 24) return t("time.hours", { n: hours });
|
|
45
46
|
const days = Math.floor(hours / 24);
|
|
46
|
-
return
|
|
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">
|
|
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 &&
|
|
141
|
+
{s.pr_state && STATE_CLASS[s.pr_state] && (
|
|
136
142
|
<>
|
|
137
143
|
<span className="text-muted-foreground/15">·</span>
|
|
138
|
-
<span className={
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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" />
|
|
291
|
+
<><Loader2 className="h-3 w-3 animate-spin" /> {t("appShell.updating")}</>
|
|
285
292
|
) : (
|
|
286
|
-
<><Download className="h-3 w-3" />
|
|
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">
|
|
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={
|
|
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="
|
|
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
|
-
|
|
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>
|
|
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 ?
|
|
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">
|
|
219
|
-
<p className="text-[10px] text-muted-foreground/20 mt-1"
|
|
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">
|
|
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">
|
|
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: "
|
|
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="
|
|
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
|
-
|
|
349
|
+
{t("chat.atToRef")}
|
|
344
350
|
</span>
|
|
345
351
|
<span className="text-[10px] text-muted-foreground/25">
|
|
346
|
-
|
|
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">
|
|
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
|
-
|
|
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">
|
|
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">
|
|
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">
|
|
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}
|
|
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): {
|
|
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 {
|
|
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">{
|
|
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
|
-
{
|
|
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 ? "
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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}
|
|
61
|
+
{t("feasibility.unassignedFiles", { n: result.unassigned_paths.length })}
|
|
60
62
|
</p>
|
|
61
63
|
)}
|
|
62
64
|
</div>
|