Guide

phase와 snapshot

headless 클라이언트의 상태 모델(phase)과 UI가 받아 쓰는 snapshot·타입 구조를 설명합니다.

headless 클라이언트의 상태는 하나의 읽기 전용 객체 snapshot으로 표현됩니다. 직접 만든 UI는 이 값을 보고 무엇을 그릴지 결정합니다.

phase 모델

snapshot의 중심에는 snapshot.phase가 있습니다. UI는 phase를 보고 어떤 버튼·폼·로딩을 보여줄지 결정합니다. phase를 직접 바꾸는 API는 없고, 메서드를 호출하면 phase가 알아서 전환됩니다.

phase의미이 상태에서 주로 호출하는 메서드
initializing준비 중 (API key 검증)
idle대기 상태refreshIssues, openIssue, startIssueCapture, startVideoCapture, loginAsMember
loadingIssues이슈 목록 불러오는 중
viewingIssue이슈 상세 보는 중closeIssue, refreshIssues, openIssue, startIssueCapture, startVideoCapture
recordingVideo화면 녹화 중stopVideoCapture, cancelIssueCapture
selectingTarget사용자가 캡처할 요소를 고르는 중cancelIssueCapture
draftingIssue제목·설명·필드를 입력하는 중setSessionReplayEnabled, submitDraft, cancelIssueCapture
submittingIssue이슈를 생성하는 중 (캡처가 진행 중이었다면 완료 대기 포함)

selectingTarget phase에서 요소를 클릭하면 즉시 draftingIssue로 전환됩니다. 스크린샷 캡처와 폼 스키마 로드는 백그라운드에서 진행되어 완료되는 대로 snapshot에 반영됩니다.

각 메서드는 허용되지 않은 phase에서 호출하면 에러를 던집니다. 예를 들어 요소를 아직 고르지 않았는데 submitDraft(...)를 호출하면 code: 'INVALID_PHASE'로 거부됩니다.

snapshot 구조

subscribe·getSnapshot으로 받는 HeadlessSnapshot의 필드입니다. 모두 읽기 전용입니다.

필드타입설명
phaseHeadlessPhase현재 동작 단계. UI 분기의 기준
authHeadlessAuthState로그인 모드(public/member)와 준비 여부
issuesIssueStickerSummary[]불러온 이슈 목록. 스티커 렌더링에 사용
activeIssueIssue | null상세 보기 중인 이슈
draftHeadlessIssueDraft | null캡처 컨텍스트(위치·스크린샷 프리뷰·미디어 모드 등). 제목·설명·필드 값은 앱이 직접 관리
videoCaptureHeadlessVideoCaptureState녹화 상태(isRecording 등)
sessionReplayHeadlessSessionReplayState세션 리플레이 사용 가능·첨부 여부
formSchemaIssueFormSchema | null생성 폼에 그릴 동적 필드 목록
filterSchemaIssueFilterSchema | null조회 필터에 그릴 필터 그룹
errorIssueStickerHeadlessError | null마지막으로 발생한 에러

주요 타입

폼·필터를 그릴 때 자주 쓰는 타입입니다.

HeadlessConfig — 클라이언트 생성 옵션

interface HeadlessConfig {
  apiKey: string; // 필수: 프로젝트 API key
  user?: { id: string; name: string }; // 선택: 사용자 식별
  capture?: {
    networkLogs?: boolean; // 네트워크 로그 수집 여부
    consoleLogs?: boolean; // 콘솔 로그 수집 여부
    excludeSelectors?: string[]; // 캡처에서 제외할 요소 선택자
  };
}

DynamicFieldDescriptor — 동적 필드 하나를 그리기 위한 정보

Jira·Notion·커스텀 필드는 프로젝트 설정에 따라 런타임에 달라지므로, 고정된 타입 대신 이 descriptor로 내려옵니다. snapshot.formSchema.fields에 담기며 inputType에 맞춰 입력 컴포넌트를 그리면 됩니다.

interface DynamicFieldDescriptor {
  id: string;
  source: 'jira' | 'notion' | 'custom';
  label: string;
  inputType:
    | 'text'
    | 'number'
    | 'select'
    | 'multi_select'
    | 'date'
    | 'checkbox'
    | 'user'
    | 'issue'
    | 'people'
    | 'readonly';
  options: readonly { label: string; value: string }[];
  search?: (query: string) => Promise<readonly { label: string; value: string }[]>;
  resolveValueLabel?: (value: string) => Promise<string | null>;
}

필드 값은 앱 상태에 담아 두었다가 submitDraftdynamicFields로 전달합니다. 키는 field.id입니다.

function renderField(
  field: DynamicFieldDescriptor,
  value: unknown,
  onChange: (fieldId: string, value: unknown) => void
) {
  switch (field.inputType) {
    case 'select':
      return renderSelect({
        options: field.options,
        value,
        onChange: (v) => onChange(field.id, v),
      });
    case 'user':
    case 'issue':
    case 'people':
      return renderAsyncCombobox({
        loadOptions: field.search,
        value,
        onChange: (v) => onChange(field.id, v),
      });
    case 'checkbox':
      return renderCheckbox({ checked: Boolean(value), onChange: (v) => onChange(field.id, v) });
    default:
      return renderText({ value: String(value ?? ''), onChange: (v) => onChange(field.id, v) });
  }
}

동적 필드는 멤버 모드에서만 제공됩니다. public 모드에서는 formSchema.unavailableReason === 'public-mode'로 표시됩니다.

HeadlessIssueFiltersrefreshIssues에 넘기는 조회 조건

interface HeadlessIssueFilters {
  url?: string; // 특정 URL 또는 'all'
  search?: string; // 제목·설명 검색어
  externalUserId?: string; // 특정 사용자의 이슈만
  customFieldValues?: Record<string, string[]>;
  jiraFieldValues?: Record<string, string[]>;
  notionFieldValues?: Record<string, string[]>;
}

이 값들을 실제로 어떻게 호출하는지는 클라이언트 메서드에서 다룹니다.

phase와 snapshot — 이슈스티커 Guide