Skip to content

Commit fa1598e

Browse files
committed
feat(web): indicateur de Cadre en haut du fil + section Documents verrouillée à la création
- Conversation : bandeau discret « Cadre : <nom> » collé en haut du fil (réactif au sélecteur, masqué s'il n'y a pas de Cadre) → on sait toujours dans quel mode la conversation tourne. - Éditeur de Cadre : pendant la création, la section « Documents de référence » est affichée mais floutée/désactivée avec un cadenas et le message « Enregistrez d'abord le cadre » (au lieu de n'apparaître qu'après création).
1 parent dd9e4a7 commit fa1598e

4 files changed

Lines changed: 159 additions & 89 deletions

File tree

apps/web/app/_components/chat-thread.tsx

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import type { DocumentDTO, FrameworkDTO } from '@/lib/types';
1515
import { Composer } from './composer';
1616
import { EmptyState } from './empty-state';
17-
import { RefreshIcon } from './icons';
17+
import { RefreshIcon, SparkIcon } from './icons';
1818
import { MessageItem } from './message-item';
1919

2020
/**
@@ -112,44 +112,55 @@ export function ChatThread({
112112

113113
const busy = status === 'submitted' || status === 'streaming';
114114
const lastIndex = messages.length - 1;
115+
const activeFramework = frameworks.find((f) => f.id === frameworkId) ?? null;
115116

116117
return (
117118
<div className="flex h-full min-h-0 flex-col">
118119
<div className="flex-1 overflow-y-auto">
119-
<div className="mx-auto max-w-3xl space-y-7 px-4 pt-8 pb-4">
120-
{messages.length === 0 ? (
121-
<EmptyState onPick={(text) => sendMessage({ text })} />
122-
) : (
123-
messages.map((message, index) => (
124-
<MessageItem
125-
key={message.id}
126-
message={message}
127-
streaming={busy && index === lastIndex && message.role === 'assistant'}
128-
/>
129-
))
130-
)}
131-
132-
{error ? (
133-
<div
134-
role="alert"
135-
className="flex flex-wrap items-center gap-3 rounded-card border border-danger/30 bg-danger/10 px-4 py-3 text-sm"
136-
>
137-
<span className="text-foreground">{error.message || fr.errors.generic}</span>
138-
<button
139-
type="button"
140-
onClick={() => {
141-
clearError();
142-
regenerate();
143-
}}
144-
className="inline-flex items-center gap-1.5 rounded-button border border-border bg-surface px-2.5 py-1.5 font-medium transition-colors hover:bg-surface-muted"
145-
>
146-
<RefreshIcon className="size-4" />
147-
{fr.common.retry}
148-
</button>
120+
<div className="mx-auto max-w-3xl px-4 pb-4">
121+
{activeFramework ? (
122+
<div className="sticky top-0 z-10 -mx-4 flex justify-center bg-background/80 px-4 py-2.5 backdrop-blur">
123+
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-card">
124+
<SparkIcon className="size-3.5 text-accent" />
125+
Cadre&nbsp;: <span className="text-foreground">{activeFramework.name}</span>
126+
</span>
149127
</div>
150128
) : null}
129+
<div className={`space-y-7 ${activeFramework ? 'pt-4' : 'pt-8'}`}>
130+
{messages.length === 0 ? (
131+
<EmptyState onPick={(text) => sendMessage({ text })} />
132+
) : (
133+
messages.map((message, index) => (
134+
<MessageItem
135+
key={message.id}
136+
message={message}
137+
streaming={busy && index === lastIndex && message.role === 'assistant'}
138+
/>
139+
))
140+
)}
141+
142+
{error ? (
143+
<div
144+
role="alert"
145+
className="flex flex-wrap items-center gap-3 rounded-card border border-danger/30 bg-danger/10 px-4 py-3 text-sm"
146+
>
147+
<span className="text-foreground">{error.message || fr.errors.generic}</span>
148+
<button
149+
type="button"
150+
onClick={() => {
151+
clearError();
152+
regenerate();
153+
}}
154+
className="inline-flex items-center gap-1.5 rounded-button border border-border bg-surface px-2.5 py-1.5 font-medium transition-colors hover:bg-surface-muted"
155+
>
156+
<RefreshIcon className="size-4" />
157+
{fr.common.retry}
158+
</button>
159+
</div>
160+
) : null}
151161

152-
<div ref={bottomRef} />
162+
<div ref={bottomRef} />
163+
</div>
153164
</div>
154165
</div>
155166

apps/web/app/_components/documents-panel.tsx

Lines changed: 104 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
import { useCallback, useEffect, useRef, useState } from 'react';
44
import { deleteDocument, listDocuments, uploadDocument } from '@/lib/api';
55
import type { DocumentDTO } from '@/lib/types';
6-
import { PlusIcon, SpinnerIcon, TrashIcon } from './icons';
6+
import { LockIcon, PlusIcon, SpinnerIcon, TrashIcon } from './icons';
77

88
function formatSize(bytes: number): string {
99
if (bytes < 1024) return `${bytes} o`;
1010
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
1111
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
1212
}
1313

14-
/** Documents de référence rattachés à un Cadre : dépôt, liste, suppression. */
15-
export function DocumentsPanel({ frameworkId }: { frameworkId: string }) {
14+
/**
15+
* Documents de référence rattachés à un Cadre : dépôt, liste, suppression.
16+
* `locked` (pendant la création d'un Cadre, avant son enregistrement) : la
17+
* section reste visible mais floutée/désactivée, avec un message explicatif.
18+
*/
19+
export function DocumentsPanel({
20+
frameworkId,
21+
locked = false,
22+
}: {
23+
frameworkId?: string;
24+
locked?: boolean;
25+
}) {
1626
const [documents, setDocuments] = useState<DocumentDTO[]>([]);
1727
const [loading, setLoading] = useState(true);
1828
const [uploading, setUploading] = useState(false);
@@ -21,21 +31,26 @@ export function DocumentsPanel({ frameworkId }: { frameworkId: string }) {
2131

2232
const refresh = useCallback(
2333
async (signal?: AbortSignal) => {
34+
if (!frameworkId) return;
2435
setDocuments(await listDocuments(frameworkId, signal));
2536
},
2637
[frameworkId],
2738
);
2839

2940
useEffect(() => {
41+
if (locked || !frameworkId) {
42+
setLoading(false);
43+
return;
44+
}
3045
const controller = new AbortController();
3146
refresh(controller.signal)
3247
.catch(() => undefined)
3348
.finally(() => setLoading(false));
3449
return () => controller.abort();
35-
}, [refresh]);
50+
}, [refresh, locked, frameworkId]);
3651

3752
const onFiles = async (files: FileList | null) => {
38-
if (!files || files.length === 0) return;
53+
if (!files || files.length === 0 || !frameworkId) return;
3954
setError(null);
4055
setUploading(true);
4156
try {
@@ -62,61 +77,94 @@ export function DocumentsPanel({ frameworkId }: { frameworkId: string }) {
6277
sources.
6378
</p>
6479

65-
<div className="mt-4">
66-
<input
67-
ref={inputRef}
68-
type="file"
69-
accept=".pdf,.txt,.md,.markdown,application/pdf,text/plain,text/markdown"
70-
multiple
71-
className="sr-only"
72-
onChange={(e) => onFiles(e.target.files)}
73-
/>
74-
<button
75-
type="button"
76-
onClick={() => inputRef.current?.click()}
77-
disabled={uploading}
78-
className="inline-flex items-center gap-1.5 rounded-button border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors hover:border-accent hover:bg-surface-muted disabled:opacity-60"
80+
<div className="relative mt-4">
81+
<div
82+
aria-hidden={locked || undefined}
83+
className={locked ? 'pointer-events-none select-none blur-[3px] opacity-50' : undefined}
7984
>
80-
{uploading ? <SpinnerIcon className="size-4" /> : <PlusIcon className="size-4" />}
81-
{uploading ? 'Analyse en cours…' : 'Déposer un document'}
82-
</button>
83-
</div>
85+
<input
86+
ref={inputRef}
87+
type="file"
88+
accept=".pdf,.txt,.md,.markdown,application/pdf,text/plain,text/markdown"
89+
multiple
90+
disabled={locked}
91+
className="sr-only"
92+
onChange={(e) => onFiles(e.target.files)}
93+
/>
94+
<button
95+
type="button"
96+
onClick={() => inputRef.current?.click()}
97+
disabled={uploading || locked}
98+
className="inline-flex items-center gap-1.5 rounded-button border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors hover:border-accent hover:bg-surface-muted disabled:opacity-60"
99+
>
100+
{uploading ? <SpinnerIcon className="size-4" /> : <PlusIcon className="size-4" />}
101+
{uploading ? 'Analyse en cours…' : 'Déposer un document'}
102+
</button>
84103

85-
{error ? (
86-
<p role="alert" className="mt-2 text-sm text-danger">
87-
{error}
88-
</p>
89-
) : null}
104+
{error ? (
105+
<p role="alert" className="mt-2 text-sm text-danger">
106+
{error}
107+
</p>
108+
) : null}
90109

91-
<div className="mt-3">
92-
{loading ? (
93-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
94-
<SpinnerIcon className="size-4" /> Chargement…
95-
</div>
96-
) : documents.length === 0 ? (
97-
<p className="text-sm text-muted-foreground">Aucun document pour ce cadre.</p>
98-
) : (
99-
<ul className="divide-y divide-border">
100-
{documents.map((document) => (
101-
<li key={document.id} className="flex items-center justify-between gap-3 py-2">
102-
<div className="min-w-0">
103-
<p className="truncate text-sm font-medium">{document.filename}</p>
104-
<p className="text-xs text-muted-foreground">
105-
{formatSize(document.sizeBytes)} · {document.chunkCount} passage(s)
106-
</p>
110+
{locked ? (
111+
// Aperçu factice (squelette) pour donner à voir la section à venir.
112+
<ul className="mt-3 divide-y divide-border">
113+
{['•••••••••.pdf', '••••••.txt'].map((label) => (
114+
<li key={label} className="flex items-center justify-between gap-3 py-2">
115+
<div className="min-w-0">
116+
<p className="truncate text-sm font-medium">{label}</p>
117+
<p className="text-xs text-muted-foreground">— · — passage(s)</p>
118+
</div>
119+
<TrashIcon className="size-4 text-muted-foreground" />
120+
</li>
121+
))}
122+
</ul>
123+
) : (
124+
<div className="mt-3">
125+
{loading ? (
126+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
127+
<SpinnerIcon className="size-4" /> Chargement…
107128
</div>
108-
<button
109-
type="button"
110-
onClick={() => onDelete(document.id)}
111-
aria-label={`Supprimer ${document.filename}`}
112-
className="inline-flex size-8 shrink-0 items-center justify-center rounded-button text-muted-foreground hover:bg-surface-muted hover:text-danger"
113-
>
114-
<TrashIcon className="size-4" />
115-
</button>
116-
</li>
117-
))}
118-
</ul>
119-
)}
129+
) : documents.length === 0 ? (
130+
<p className="text-sm text-muted-foreground">Aucun document pour ce cadre.</p>
131+
) : (
132+
<ul className="divide-y divide-border">
133+
{documents.map((document) => (
134+
<li key={document.id} className="flex items-center justify-between gap-3 py-2">
135+
<div className="min-w-0">
136+
<p className="truncate text-sm font-medium">{document.filename}</p>
137+
<p className="text-xs text-muted-foreground">
138+
{formatSize(document.sizeBytes)} · {document.chunkCount} passage(s)
139+
</p>
140+
</div>
141+
<button
142+
type="button"
143+
onClick={() => onDelete(document.id)}
144+
aria-label={`Supprimer ${document.filename}`}
145+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-button text-muted-foreground hover:bg-surface-muted hover:text-danger"
146+
>
147+
<TrashIcon className="size-4" />
148+
</button>
149+
</li>
150+
))}
151+
</ul>
152+
)}
153+
</div>
154+
)}
155+
</div>
156+
157+
{locked ? (
158+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 rounded-card bg-surface/55 text-center">
159+
<span className="inline-flex size-9 items-center justify-center rounded-full bg-surface-muted text-muted-foreground shadow-card">
160+
<LockIcon className="size-4" />
161+
</span>
162+
<p className="text-sm font-semibold text-foreground">Enregistrez d’abord le cadre</p>
163+
<p className="max-w-xs text-xs text-muted-foreground">
164+
Vous pourrez ensuite y ajouter des documents de référence.
165+
</p>
166+
</div>
167+
) : null}
120168
</div>
121169
</section>
122170
);

apps/web/app/_components/icons.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ export function PowerIcon(props: IconProps) {
255255
);
256256
}
257257

258+
export function LockIcon(props: IconProps) {
259+
return (
260+
<Svg {...props}>
261+
<rect x="4" y="11" width="16" height="10" rx="2" />
262+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
263+
</Svg>
264+
);
265+
}
266+
258267
export function MicIcon(props: IconProps) {
259268
return (
260269
<Svg {...props}>

apps/web/app/cadres/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ export default function CadresPage() {
185185
onSave={handleSave}
186186
onCancel={handleCancel}
187187
documentsSlot={
188-
!creating && activeFramework ? (
188+
creating ? (
189+
<DocumentsPanel locked />
190+
) : activeFramework ? (
189191
<DocumentsPanel key={activeFramework.id} frameworkId={activeFramework.id} />
190192
) : undefined
191193
}

0 commit comments

Comments
 (0)