DEV Community

Rahul
Rahul

Posted on

Testcraft Studio frontend 25: src/pages/Chat.jsx

import React, { useEffect, useRef, useState } from 'react';
import Icon from '../Icon.jsx';
import TemplateUpload from '../TemplateUpload.jsx';
import { ChatSources, ChatTestDraft } from '../ChatResults.jsx';
import { assertChatContext, buildChatRequest, requestWorkspaceAssistant, saveChatDraft, sourceCitation, testKinds } from '../chatAssistant.js';
import { suiteExport } from '../suiteExport.js';
import { saveClarification } from '../access.js';
import { useStudio, contextKey, eligibleDocuments, questionsFor, timestamp, uid, bumpVersion, download, log } from '../store.jsx';
import { Badge, Button, ContextBar, Empty, Field, Heading, Modal, Note, SourceDialog, UploadBox, WorkspaceRequired } from '../ui.jsx';
import '../chat.css';

const starters = [
  { icon: 'chat', title: 'Understand your knowledge', text: 'Ask a question about your documents.', prompt: 'What are the member validation requirements?' },
  { icon: 'spark', title: 'Create test cases', text: 'Turn requirements into a testable draft.', prompt: 'Generate technical test cases for the selected requirements' },
  { icon: 'file', title: 'Find the source', text: 'See the evidence behind an answer.', prompt: 'Show sources for the selected knowledge' },
];

export default function Chat({ navigate }) {
  const { state: s, workspace: w, mutate, notify } = useStudio();
  const [input, setInput] = useState(''), [kind, setKind] = useState('Auto'), [templateId, setTemplateId] = useState(w?.templateId || '');
  const [selected, setSelected] = useState(''), [source, setSource] = useState(null), [dialog, setDialog] = useState('');
  const [answer, setAnswer] = useState(''), [reference, setReference] = useState(''), [answerSource, setAnswerSource] = useState('');
  const [historic, setHistoric] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
  const bottom = useRef(null), composer = useRef(null), controller = useRef(null), latest = useRef(s);
  latest.current = s;
  const key = w ? contextKey(w) : '', activeKey = historic || key;
  const thread = s.threads[activeKey], messages = thread?.messages || [];
  const templates = s.templates.filter(t => t.team === s.session.team && (t.visibility !== 'workspace' || t.workspaceId === w?.id));
  const selectedTemplate = templates.find(t => t.id === templateId) || templates.find(t => t.id === w?.templateId) || templates[0];
  useEffect(() => {
    controller.current?.abort(); controller.current = null; setBusy(false); setError('');
    setHistoric(''); setSelected(''); setKind('Auto'); setInput('');
  }, [key]);
  useEffect(() => () => controller.current?.abort(), []);
  useEffect(() => { setTemplateId(w?.templateId || ''); }, [w?.id, w?.templateId]);
  useEffect(() => { bottom.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); }, [messages.length, busy]);
  if (!w) return <WorkspaceRequired navigate={navigate}/>;
  const docs = eligibleDocuments(s, w).filter(d => ['Ready', 'Reviewed locally'].includes(d.status));
  const baseline = s.baselines.find(b => b.id === w.baselineId);
  const questions = questionsFor(s, w), current = questions.find(q => q.id === selected) || questions.find(q => q.status !== 'resolved') || questions[0];
  const suggestions = [...messages].reverse().find(m => m.role === 'assistant')?.suggestions || [];
  const openSource = value => setSource(typeof value === 'string' ? sourceCitation(s.documents.find(d => d.id === value) || { id: value }) : value);
  function prompt(text) { if (historic || busy) return; setInput(text); setError(''); composer.current?.focus(); }
  function chooseTemplate(id) {
    setTemplateId(id);
    mutate(d => { const owner = d.workspaces.find(item => item.id === w.id); if (owner) owner.templateId = id; });
  }
  function stop() { controller.current?.abort(); controller.current = null; setBusy(false); setError('Request stopped. You can send another message.'); }
  async function send(event) {
    event.preventDefault(); if (!input.trim() || historic || controller.current) return;
    let request;
    try { request = buildChatRequest(s, w, { prompt: input, kind, templateId: selectedTemplate?.id, messages }); }
    catch (failure) { setError(failure.message); return; }
    const pending = new AbortController(), snapshot = structuredClone(s);
    controller.current = pending; setBusy(true); setError(''); setInput('');
    mutate(d => {
      if (!d.threads[key]) d.threads[key] = { workspaceId: w.id, version: w.version, team: w.team, baselineId: w.baselineId, crIds: [...w.crIds], label: `${baseline?.ref || w.ref} · Context ${w.version}`, messages: [] };
      d.threads[key].messages.push({ id: uid('message'), role: 'user', content: request.prompt, at: timestamp(), actor: s.session.name });
    });
    try {
      const response = await requestWorkspaceAssistant(request, { state: snapshot, signal: pending.signal });
      if (pending.signal.aborted || controller.current !== pending) return;
      assertChatContext(latest.current, request);
      mutate(d => {
        try { assertChatContext(d, request); } catch { return; }
        d.threads[key].messages.push({ ...response, id: uid('message'), role: 'assistant', actor: 'Workspace assistant', at: timestamp(), sourceIds: response.citations.map(c => c.id) });
      });
    } catch (failure) {
      if (failure.name !== 'AbortError' && controller.current === pending) { setError(failure.message); setInput(request.prompt); }
    } finally { if (controller.current === pending) { controller.current = null; setBusy(false); } }
  }
  function save(message) {
    try {
      saveChatDraft(structuredClone(s), w, message.id, activeKey);
      mutate(d => { saveChatDraft(d, w, message.id, activeKey); log(d, `Saved ${message.draft.kind.toLowerCase()} tests from workspace chat`); });
      notify('Test cases saved to Test suites.');
    } catch (failure) { setError(failure.message); }
  }
  function exportDraft(message) {
    try {
      assertChatContext(s, { context: message.requestContext });
      const suite = s.suites.find(item => item.id === message.savedSuiteId) || message.draft;
      const file = suiteExport(s, w, suite); download(file.filename, file.content, file.mimeType); notify('Test cases exported using your selected template columns.');
    } catch (failure) { setError(failure.message); }
  }
  function submitAnswer(event) {
    event.preventDefault();
    try {
      saveClarification(structuredClone(s), w, current.id, answer, answerSource, reference, docs);
      mutate(d => { saveClarification(d, w, current.id, answer, answerSource, reference, docs); log(d, `Resolved ${current.requirement} with source-backed evidence`); });
      setDialog(''); notify('Answer saved. You can refine the test cases in chat.');
    } catch (failure) { notify(failure.message); }
  }
  return <>
    <Heading eyebrow={`${w.ref} · ${w.name.toUpperCase()}`} title="Workspace chat" description="Ask questions. Create test cases. Follow the evidence."><div className="chat-heading-actions"><Button icon="clock" onClick={() => setDialog('history')}>Thread history</Button><Button icon="checkSquare" onClick={() => navigate('suites')}>Test suites</Button></div></Heading>
    <ContextBar navigate={navigate}/>
    {historic && <div className="history-banner"><Icon name="clock" size={16}/>{thread?.label} · Read-only historical thread<Button onClick={() => setHistoric('')}>Return to current context</Button></div>}
    <div className="chat-grid chat-studio"><section className="card conversation">
      <div className="conversation-heading"><div className="assistant-label"><span className="assistant-symbol"><Icon name="spark" size={19}/></span><span>Workspace assistant<small>Answers and test cases, with sources</small></span></div><Button icon="book" onClick={() => setDialog('sources')}>View sources</Button></div>
      <div className="chat-messages" role="log" aria-label="Workspace conversation" aria-live="polite" aria-relevant="additions">
        {!messages.length && <div className="chat-welcome"><span className="chat-welcome-symbol"><Icon name="spark" size={29}/></span><div className="chat-kicker">YOUR WORKSPACE, ONE CONVERSATION</div><h2>What would you like to test?</h2><p>Explore your documents, turn requirements into test cases, and see where every answer comes from.</p><div className="chat-starters">{starters.map(item => <button key={item.title} onClick={() => prompt(item.prompt)}><Icon name={item.icon} size={20}/><strong>{item.title}</strong><span>{item.text}</span><Icon name="arrow" size={14}/></button>)}</div></div>}
        {messages.map(message => {
          const citations = message.citations || (message.sourceIds || []).map(id => s.documents.find(d => d.id === id)).filter(Boolean).map(d => sourceCitation(d));
          const savedSuite = s.suites.find(item => item.id === message.savedSuiteId);
          return <article className={`message message-${message.role}`} key={message.id}><span className={`message-avatar ${message.role === 'user' ? 'user-avatar' : 'assistant-avatar'}`}><Icon name={message.role === 'user' ? 'users' : 'spark'} size={16}/></span><div className="chat-message-content"><div className="message-author">{message.actor}<small>{new Date(message.at).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}</small></div><div className="message-bubble"><div className="chat-response-text">{message.content}</div>{message.draft && <ChatTestDraft message={message} savedSuite={savedSuite} historical={!!historic} busy={busy} onSource={openSource} onSave={save} onOpen={id => navigate(`suites?suite=${encodeURIComponent(id)}`)} onExport={exportDraft} onPrompt={prompt}/>} {!!citations.length && <details className="chat-message-sources" open={message.intent === 'sources'}><summary>{message.draft ? 'Evidence behind these cases' : 'Supporting sources'}</summary><ChatSources citations={citations} onSource={openSource}/></details>}</div></div></article>;
        })}
        {busy && <div className="chat-working" role="status"><Icon name="clock" size={16}/>Working with your workspace evidence<Button onClick={stop}>Stop</Button></div>}<div ref={bottom}/>
      </div>
      {!!suggestions.length && !historic && <div className="chat-followups" aria-label="Suggested follow-ups">{suggestions.map(text => <button key={text} disabled={busy} onClick={() => prompt(text)}>{text}<Icon name="arrow" size={11}/></button>)}</div>}
      {error && <div className="chat-error" role="alert"><Icon name="info" size={16}/><span>{error}</span><button aria-label="Dismiss message" onClick={() => setError('')}><Icon name="close" size={14}/></button></div>}
      <form className="chat-composer" onSubmit={send}><div className="chat-composer-top"><label htmlFor="workspace-question">Message your workspace</label><label className="chat-kind-select"><span>Test type</span><select aria-label="Test case type" value={kind} disabled={!!historic || busy} onChange={e => setKind(e.target.value)}>{testKinds.map(type => <option key={type}>{type}</option>)}</select></label></div><textarea ref={composer} id="workspace-question" disabled={!!historic || busy} value={input} onChange={e => setInput(e.target.value)} placeholder={historic ? 'This historical conversation is read-only.' : 'Ask about your documents, or describe the test cases you need…'} aria-label="Message your workspace" rows={3} maxLength={12000} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(e); } }}/><div className="composer-bottom"><button type="button" className="attach-button" disabled={!!historic || busy} onClick={() => setDialog('attach')}><Icon name="attach" size={16}/>Attach evidence</button><span>Enter to send · Shift + Enter for a new line</span>{busy ? <button type="button" className="chat-stop" onClick={stop} aria-label="Stop workspace request"><span/>Stop</button> : <button type="submit" className="send-button" disabled={!input.trim() || !!historic} aria-label="Send workspace message"><Icon name="arrowUp" size={18}/></button>}</div></form>
      <div className="chat-footer"><Icon name="shield" size={11}/>Workspace {w.ref} · Context {historic ? thread?.version : w.version}<span>Review generated cases against their sources.</span></div>
    </section><aside className="chat-aside">
      <section className="card chat-template-card"><div className="chat-kicker">TEST-CASE FORMAT</div><h2>Your format, every time.</h2><p>Choose a template before generating, or upload the Excel format you already use.</p><label className="chat-template-label" htmlFor="chat-template">Selected template</label><select id="chat-template" aria-label="Chat test-case template" value={selectedTemplate?.id || ''} disabled={!!historic || busy} onChange={e => chooseTemplate(e.target.value)}>{templates.map(template => <option key={template.id} value={template.id}>{template.name} · v{template.version}</option>)}</select>{selectedTemplate && <div className="chat-template-fields">{selectedTemplate.fields.slice(0, 4).map(field => <span key={field}>{field}</span>)}{selectedTemplate.fields.length > 4 && <span>+{selectedTemplate.fields.length - 4} columns</span>}</div>}<TemplateUpload disabled={!!historic || busy} onSelected={setTemplateId}/></section>
      <section className="card chat-context-card"><div className="section-heading"><h2>Selected knowledge</h2><Icon name="layers" size={16}/></div><div><span>System baseline</span><strong>{baseline?.ref || 'Not selected'}{baseline && ` · v${baseline.version}`}</strong></div><div><span>Workspace</span><strong>{w.ref}</strong></div><div><span>Change requests</span><strong>{w.crIds.map(id => s.crs.find(cr => cr.id === id)?.ref).filter(Boolean).join(', ') || 'None selected'}</strong></div><button className="text-button" onClick={() => setDialog('sources')}>Browse source documents <Icon name="arrow" size={13}/></button><p className="chat-evidence-note"><Icon name="shield" size={15}/>Answers and drafts use the evidence selected for this workspace.</p></section>
      <details className="card clarification-panel optional-clarifications"><summary>Optional clarifications</summary><p className="panel-description">Generate now. Add answers whenever you want to refine the results.</p>{questions.map(q => <button className={`clarification-option ${current?.id === q.id ? 'selected' : ''}`} onClick={() => setSelected(q.id)} key={q.id}><span><strong>{q.title}</strong><small>{q.requirement} · {q.status}</small></span><Icon name="chevron" size={14}/></button>)}{current ? <div className="question-detail"><Badge tone={current.status === 'resolved' ? 'green' : 'amber'}>{current.status === 'resolved' ? 'Resolved' : 'Optional answer'}</Badge><h3>{current.title}</h3><p>{current.why}</p><p className="margin-top">{current.requested}</p><button className="text-button margin-top" onClick={() => openSource(current.sourceId)}>Open cited source <Icon name="external" size={12}/></button>{current.answer && <div className="answer-preview"><small>USER-PROVIDED EVIDENCE</small><p>{current.answer}</p><span>{current.sourceReference}</span></div>}<div className="stack-actions"><Button disabled={!!historic || busy} onClick={() => { setAnswer(current.answer || ''); setReference(current.sourceReference || ''); setAnswerSource(current.answerSourceId || (docs.some(d => d.id === current.sourceId) ? current.sourceId : '')); setDialog('answer'); }}>{current.answer ? 'Edit answer' : 'Answer clarification'}</Button></div></div> : <p className="panel-description">No open questions for this context.</p>}</details>
    </aside></div>
    {dialog === 'sources' && <Modal title="Workspace sources" close={() => setDialog('')} wide><p className="modal-intro">Open a document to inspect its selected revision and evidence.</p>{docs.length ? <ChatSources citations={docs.map(doc => sourceCitation(doc))} onSource={value => { setDialog(''); openSource(value); }}/> : <Empty title="Add your first source" description="Choose a baseline and reviewed source documents for this workspace."/>}</Modal>}
    {dialog === 'answer' && current && <Modal title="Answer a clarification" close={() => setDialog('')}><p className="modal-intro">{current.title}</p><form onSubmit={submitAnswer}><Field label="Behavior / answer"><textarea required rows={5} value={answer} onChange={e => setAnswer(e.target.value)} maxLength={5000}/></Field><Field label="Supporting evidence source"><select required value={answerSource} onChange={e => setAnswerSource(e.target.value)}><option value="">Choose reviewed evidence</option>{docs.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}</select></Field><Field label="Source revision and location"><input required value={reference} onChange={e => setReference(e.target.value)} placeholder="Document, revision, page / section / procedure" maxLength={300}/></Field><Note>Save the answer with an accessible source and its revision and location.</Note><div className="modal-actions"><Button onClick={() => setDialog('')}>Cancel</Button><Button type="submit" variant="primary">Save answer and resolve</Button></div></form></Modal>}
    {dialog === 'history' && <Modal title="Conversation history" close={() => setDialog('')}><p className="modal-intro">Conversations keep the knowledge context they were created with.</p>{Object.entries(s.threads).filter(([, t]) => t.workspaceId === w.id && t.team === s.session.team).map(([id, t]) => <button className="history-row" key={id} onClick={() => { controller.current?.abort(); controller.current = null; setBusy(false); setError(''); setHistoric(id === key ? '' : id); setDialog(''); }}><Icon name="chat" size={18}/><span><strong>{t.label}</strong><small>{id === key ? 'Current context' : 'Read-only history'}</small></span><Icon name="chevron" size={14}/></button>)}{!Object.values(s.threads).some(t => t.workspaceId === w.id && t.team === s.session.team) && <Empty title="No saved conversations" description="Send a message to start a conversation."/>}</Modal>}
    {dialog === 'attach' && <Modal title="Attach supporting evidence" close={() => setDialog('')}><Note>Add a document, then review and link its requirements in CR evidence before generating test cases from it.</Note><div className="margin-top"><UploadBox scope={{ team: w.team, workspaceId: w.id, category: 0 }} onStaged={files => { mutate(d => { d.documents.push(...files); bumpVersion(d, d.workspaces.find(item => item.id === w.id)); log(d, `Attached chat evidence to ${w.ref}`); }); setDialog(''); notify('Evidence added. Review its routing in CR evidence.'); }}/></div></Modal>}
    {source && <SourceDialog sourceId={source.id} citation={source} close={() => setSource(null)}/>}
  </>;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)