Skip to content

Commit 8177d3e

Browse files
authored
Merge pull request #422 from Mng-dev-ai/refactor/frontend-deduplication
Deduplicate frontend code with shared abstractions
2 parents edb831b + 99a21cd commit 8177d3e

79 files changed

Lines changed: 1931 additions & 2183 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@
5959
- Module-level constants must be placed at the top of the file, immediately after imports and logger/settings initialization — never between classes or functions
6060
- Prefer simple env-var/config-based solutions over runtime introspection — e.g., use a `HOST_STORAGE_PATH` env var to map container paths to host paths instead of inspecting Docker container mounts at runtime
6161
- When two methods in the same class share the same lifecycle (one always calls the other), do not duplicate work in the caller that the callee already performs — let the callee handle it once
62+
- When refactoring code that has `try/catch/finally` blocks, preserve cleanup logic in `finally` — do not move cleanup after an `await` without wrapping it in `try/finally`, or it will be skipped on failure
63+
- When extracting a shared utility from multiple callers with slightly different semantics, verify behavioral equivalence for every caller — especially for edge-case inputs like `null`, `undefined`, `0`, and empty strings
64+
- When accepting a caller-provided options/config object and spreading it into a builder, use `Omit<>` to exclude keys the factory controls — prevents silent shadowing at the type level instead of runtime `_ignored` destructuring
6265

6366
## Naming Conventions
6467

@@ -109,6 +112,7 @@
109112
### File Placement
110113
- When extracting non-component code (contexts, utils, hooks) from a component file, place it in the project's canonical folder for that type (`contexts/`, `utils/`, `hooks/`) — do not leave it next to the component it was extracted from
111114
- The `components/chat/tools/` directory is exclusively for tool components (one per tool type) — helper modals, dialogs, and detail views triggered by tools belong in `components/chat/` or a relevant feature folder, not in `tools/`
115+
- Shared UI components used by 2+ feature areas belong in `components/ui/shared/` — do not place them loose in a feature folder just because the first consumer lives there
112116

113117
### Component Variants
114118
- Create explicit variant components instead of one component with many boolean modes (e.g., `ThreadComposer`, `EditComposer` instead of `<Composer isThread isEditing />`)
@@ -139,6 +143,7 @@
139143
### Async Patterns
140144
- Use `Promise.all()` for independent async operations (e.g., multiple `queryClient.invalidateQueries()` calls)
141145
- When dynamically importing multiple libraries in the same function, parallelize with `Promise.all([import('a'), import('b')])`
146+
- When discarding a promise with `void`, attach `.catch()` to prevent silent error swallowing — `void fn().catch(err => console.error(err))`
142147

143148
## Frontend UI/UX Guidelines
144149

Lines changed: 69 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { memo, useEffect, useRef } from 'react';
2-
import { Button } from '@/components/ui/primitives/Button';
1+
import { memo, useMemo } from 'react';
2+
import { SuggestionPanel } from './SuggestionPanel';
33
import { MentionIcon } from './MentionIcon';
44
import type { MentionItem } from '@/types/ui.types';
55

@@ -10,129 +10,80 @@ interface MentionSuggestionsPanelProps {
1010
onSelect: (item: MentionItem) => void;
1111
}
1212

13+
function renderAgent(agent: MentionItem, isActive: boolean) {
14+
return (
15+
<>
16+
<MentionIcon type="agent" name={agent.name} className="h-4 w-4" />
17+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
18+
<span
19+
className={`text-xs font-medium leading-tight ${
20+
isActive
21+
? 'text-text-primary dark:text-text-dark-primary'
22+
: 'text-text-secondary dark:text-text-dark-secondary'
23+
}`}
24+
>
25+
{agent.name}
26+
</span>
27+
{agent.description && (
28+
<span className="truncate text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
29+
{agent.description}
30+
</span>
31+
)}
32+
</div>
33+
</>
34+
);
35+
}
36+
37+
function renderFile(file: MentionItem, isActive: boolean) {
38+
return (
39+
<>
40+
<MentionIcon type="file" name={file.name} className="h-4 w-4" />
41+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
42+
<span
43+
className={`font-mono text-xs leading-tight ${
44+
isActive
45+
? 'text-text-primary dark:text-text-dark-primary'
46+
: 'text-text-secondary dark:text-text-dark-secondary'
47+
}`}
48+
>
49+
{file.name}
50+
</span>
51+
<span className="truncate text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
52+
{file.path}
53+
</span>
54+
</div>
55+
</>
56+
);
57+
}
58+
59+
const agentItemKey = (item: MentionItem) => item.path;
60+
const fileItemKey = (item: MentionItem) => item.path;
61+
1362
export const MentionSuggestionsPanel = memo(function MentionSuggestionsPanel({
1463
files,
1564
agents,
1665
highlightedIndex,
1766
onSelect,
1867
}: MentionSuggestionsPanelProps) {
19-
const mentionRefs = useRef<(HTMLButtonElement | null)[]>([]);
20-
21-
const hasFiles = files.length > 0;
22-
const hasAgents = agents.length > 0;
23-
const hasSuggestions = hasFiles || hasAgents;
24-
25-
useEffect(() => {
26-
if (highlightedIndex >= 0 && mentionRefs.current[highlightedIndex]) {
27-
mentionRefs.current[highlightedIndex]?.scrollIntoView({
28-
block: 'nearest',
29-
});
30-
}
31-
}, [highlightedIndex]);
32-
33-
if (!hasSuggestions) return null;
68+
const sections = useMemo(
69+
() => [
70+
{
71+
label: 'Agents',
72+
items: agents,
73+
itemKey: agentItemKey,
74+
renderItem: renderAgent,
75+
},
76+
{
77+
label: 'Files',
78+
items: files,
79+
itemKey: fileItemKey,
80+
renderItem: renderFile,
81+
},
82+
],
83+
[agents, files],
84+
);
3485

3586
return (
36-
<div className="absolute bottom-full left-0 right-0 z-40 mb-2">
37-
<div className="max-h-64 overflow-y-auto rounded-lg border border-border bg-surface shadow-sm dark:border-border-dark dark:bg-surface-dark">
38-
<div className="py-1" role="listbox">
39-
{hasAgents && (
40-
<>
41-
<div className="px-3 py-1 text-2xs font-medium uppercase tracking-wider text-text-quaternary dark:text-text-dark-quaternary">
42-
Agents
43-
</div>
44-
{agents.map((agent, index) => {
45-
const isActive = index === highlightedIndex;
46-
return (
47-
<Button
48-
key={agent.path}
49-
ref={(el) => {
50-
mentionRefs.current[index] = el;
51-
}}
52-
type="button"
53-
variant="unstyled"
54-
role="option"
55-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left ${
56-
isActive
57-
? 'bg-surface-active dark:bg-surface-dark-active'
58-
: 'hover:bg-surface-hover dark:hover:bg-surface-dark-hover'
59-
}`}
60-
onMouseDown={(event) => {
61-
event.preventDefault();
62-
onSelect(agent);
63-
}}
64-
>
65-
<MentionIcon type="agent" name={agent.name} className="h-4 w-4" />
66-
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
67-
<span
68-
className={`text-xs font-medium leading-tight ${
69-
isActive
70-
? 'text-text-primary dark:text-text-dark-primary'
71-
: 'text-text-secondary dark:text-text-dark-secondary'
72-
}`}
73-
>
74-
{agent.name}
75-
</span>
76-
{agent.description && (
77-
<span className="truncate text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
78-
{agent.description}
79-
</span>
80-
)}
81-
</div>
82-
</Button>
83-
);
84-
})}
85-
</>
86-
)}
87-
{hasFiles && (
88-
<>
89-
<div className="px-3 py-1 text-2xs font-medium uppercase tracking-wider text-text-quaternary dark:text-text-dark-quaternary">
90-
Files
91-
</div>
92-
{files.map((file, index) => {
93-
const globalIndex = agents.length + index;
94-
const isActive = globalIndex === highlightedIndex;
95-
return (
96-
<Button
97-
key={file.path}
98-
ref={(el) => {
99-
mentionRefs.current[globalIndex] = el;
100-
}}
101-
type="button"
102-
variant="unstyled"
103-
role="option"
104-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left ${
105-
isActive
106-
? 'bg-surface-active dark:bg-surface-dark-active'
107-
: 'hover:bg-surface-hover dark:hover:bg-surface-dark-hover'
108-
}`}
109-
onMouseDown={(event) => {
110-
event.preventDefault();
111-
onSelect(file);
112-
}}
113-
>
114-
<MentionIcon type="file" name={file.name} className="h-4 w-4" />
115-
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
116-
<span
117-
className={`font-mono text-xs leading-tight ${
118-
isActive
119-
? 'text-text-primary dark:text-text-dark-primary'
120-
: 'text-text-secondary dark:text-text-dark-secondary'
121-
}`}
122-
>
123-
{file.name}
124-
</span>
125-
<span className="truncate text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
126-
{file.path}
127-
</span>
128-
</div>
129-
</Button>
130-
);
131-
})}
132-
</>
133-
)}
134-
</div>
135-
</div>
136-
</div>
87+
<SuggestionPanel sections={sections} highlightedIndex={highlightedIndex} onSelect={onSelect} />
13788
);
13889
});
Lines changed: 37 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { memo, useEffect, useRef } from 'react';
2-
import { Button } from '@/components/ui/primitives/Button';
1+
import { memo, useMemo } from 'react';
2+
import { SuggestionPanel } from './SuggestionPanel';
33
import type { SlashCommand } from '@/types/ui.types';
44

55
interface SlashCommandsPanelProps {
@@ -8,67 +8,47 @@ interface SlashCommandsPanelProps {
88
onSelect: (command: SlashCommand) => void;
99
}
1010

11+
function renderCommand(command: SlashCommand, isActive: boolean) {
12+
return (
13+
<>
14+
<span
15+
className={`flex-shrink-0 font-mono text-xs leading-tight ${
16+
isActive
17+
? 'text-text-primary dark:text-text-dark-primary'
18+
: 'text-text-secondary dark:text-text-dark-secondary'
19+
}`}
20+
>
21+
{command.value}
22+
</span>
23+
{command.description && (
24+
<span className="min-w-0 text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
25+
{command.description}
26+
</span>
27+
)}
28+
</>
29+
);
30+
}
31+
32+
const commandItemKey = (command: SlashCommand) => command.value;
33+
1134
export const SlashCommandsPanel = memo(function SlashCommandsPanel({
1235
suggestions,
1336
highlightedIndex,
1437
onSelect,
1538
}: SlashCommandsPanelProps) {
16-
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
17-
18-
useEffect(() => {
19-
if (highlightedIndex >= 0 && itemRefs.current[highlightedIndex]) {
20-
itemRefs.current[highlightedIndex]?.scrollIntoView({
21-
block: 'nearest',
22-
});
23-
}
24-
}, [highlightedIndex]);
25-
26-
if (suggestions.length === 0) return null;
39+
const sections = useMemo(
40+
() => [
41+
{
42+
items: suggestions,
43+
itemKey: commandItemKey,
44+
itemClassName: 'gap-6 px-3 py-1',
45+
renderItem: renderCommand,
46+
},
47+
],
48+
[suggestions],
49+
);
2750

2851
return (
29-
<div className="absolute bottom-full left-0 right-0 z-40 mb-2">
30-
<div className="max-h-64 overflow-y-auto rounded-lg border border-border bg-surface shadow-sm dark:border-border-dark dark:bg-surface-dark">
31-
<div className="py-1" role="listbox">
32-
{suggestions.map((command, index) => {
33-
const isActive = index === highlightedIndex;
34-
return (
35-
<Button
36-
key={command.value}
37-
ref={(el) => {
38-
itemRefs.current[index] = el;
39-
}}
40-
type="button"
41-
variant="unstyled"
42-
role="option"
43-
className={`flex w-full items-center gap-6 px-3 py-1 text-left ${
44-
isActive
45-
? 'bg-surface-active dark:bg-surface-dark-active'
46-
: 'hover:bg-surface-hover dark:hover:bg-surface-dark-hover'
47-
}`}
48-
onMouseDown={(event) => {
49-
event.preventDefault();
50-
onSelect(command);
51-
}}
52-
>
53-
<span
54-
className={`flex-shrink-0 font-mono text-xs leading-tight ${
55-
isActive
56-
? 'text-text-primary dark:text-text-dark-primary'
57-
: 'text-text-secondary dark:text-text-dark-secondary'
58-
}`}
59-
>
60-
{command.value}
61-
</span>
62-
{command.description && (
63-
<span className="min-w-0 text-2xs leading-tight text-text-tertiary dark:text-text-dark-tertiary">
64-
{command.description}
65-
</span>
66-
)}
67-
</Button>
68-
);
69-
})}
70-
</div>
71-
</div>
72-
</div>
52+
<SuggestionPanel sections={sections} highlightedIndex={highlightedIndex} onSelect={onSelect} />
7353
);
7454
});

0 commit comments

Comments
 (0)