Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
---
description: 코드 커밋 → (피처브랜치면) main 머지 → 푸시 한 방에
---
지금 작업물을 커밋하고 main 에 반영하고 푸시해. 순서:
1. `git status` + `git diff` 로 변경 내용 파악. 스테이징 안 된 것 포함 전부 볼 것.
2. worklog 미기재면 먼저 `python z-my-docs/work-log/gen_worklog.py add "<한 일>"` 로 남긴다 (git 작업 자체는 worklog 대상 아님 — 이번 turn 의 코드 작업이 아직 기록 안 됐을 때만).
3. `git add -A` 후, 변경 내용에 맞는 커밋 메시지를 CLAUDE.md 컨벤션대로 작성해 커밋. (co-author 트레일러 포함)
4. **브랜치 판단:**
- 지금 `main` 이면 → 머지 단계 건너뛰고 바로 push.
- 피처 브랜치면 → `git checkout main``git merge --no-ff <피처브랜치>` → push. 머지 충돌 나면 멈추고 나한테 물어봐.
5. `git push`.
6. 끝나면 최종 상태(어느 브랜치, 커밋 해시, push 결과) 한 줄로 보고.
$ARGUMENTS 가 있으면 그걸 커밋 메시지로 우선 사용.
+16
View File
@@ -0,0 +1,16 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python D:/project/021.code-assistant-v2/z-my-docs/work-log/worklog_guard.py",
"timeout": 15,
"statusMessage": "작업로그 누락 체크"
}
]
}
]
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/005-chat-image-input"
}
+9
View File
@@ -0,0 +1,9 @@
{
"ai": "claude",
"ai_skills": true,
"feature_numbering": "sequential",
"here": true,
"integration": "claude",
"script": "ps",
"speckit_version": "0.14.3.dev0"
}
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.14.3.dev0",
"integration_state_schema": 1,
"installed_integrations": [
"claude"
],
"integration_settings": {
"claude": {
"script": "ps",
"invoke_separator": "-"
}
},
"integration": "claude",
"default_integration": "claude"
}
@@ -0,0 +1,17 @@
{
"integration": "claude",
"version": "0.14.3.dev0",
"installed_at": "2026-07-27T03:19:03.825886+00:00",
"files": {
".claude/skills/speckit-analyze/SKILL.md": "0a5989b3502705af505a04f5786daa18e7ee832c7cf7261ca523300622c44c04",
".claude/skills/speckit-clarify/SKILL.md": "3f2b388505df9270aef3331e944bf45998334d10c4a3c690750fb6c45ecf746a",
".claude/skills/speckit-constitution/SKILL.md": "70a37fbeeb741ac4ecd5d9a73860ecc7d397ec61d665d304b5212e9c06749481",
".claude/skills/speckit-implement/SKILL.md": "b3406bfa211fd1ab1d31c5210799fe4c8f3c5630008dc06ae5221d41d6f6065c",
".claude/skills/speckit-converge/SKILL.md": "9eba139257c3b2e9680efb0924304eeeb60ceb211af0da301680cc9680ae5d04",
".claude/skills/speckit-plan/SKILL.md": "99feb27a4640389ab4669ba76b892f6876285b872121aa9ae7bb3322f6cc2647",
".claude/skills/speckit-checklist/SKILL.md": "e860d69567f07bb90a721a722af2b98dd705c51ad01459a2e38f9ab9a10cc454",
".claude/skills/speckit-specify/SKILL.md": "333e492196f033890734161a16e93639080956f7e74f8a64b0d4f2a7a0aff3fe",
".claude/skills/speckit-tasks/SKILL.md": "5b36c6b63d3732462da8cffb64d411a8e33f181e301971aa6f848efe800a1ab5",
".claude/skills/speckit-taskstoissues/SKILL.md": "2a7f6d9485ae92712c8022c8c345ed769752ef16c876a259b7535dc338e9a744"
}
}
@@ -0,0 +1,17 @@
{
"integration": "speckit",
"version": "0.14.3.dev0",
"installed_at": "2026-07-27T03:19:04.016959+00:00",
"files": {
".specify/scripts/powershell/check-prerequisites.ps1": "d7771e53f07d45b7b820e1659a41f58d570dd07d05ad47556abb3953023037f2",
".specify/scripts/powershell/common.ps1": "9c78cd7165e4646087742b384ac3b67142ca4c719552150501e2591ec39e7252",
".specify/scripts/powershell/create-new-feature.ps1": "dbc275bc999ebc0d7022f9725bf022674cf97a435fa57c32439d161b3e9844c1",
".specify/scripts/powershell/setup-plan.ps1": "76262f13b36c02244aa4217b076997c1489da27528e3d4eb01fcf639bff50eaf",
".specify/scripts/powershell/setup-tasks.ps1": "401462bf60e2e4a4c5ccd1b4d1a67b0b3f249557eb9cf956a998e64904fc953f",
".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4",
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842"
}
}
@@ -0,0 +1,4 @@
{
"sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
"source": "core"
}
+50
View File
@@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
@@ -0,0 +1,153 @@
#!/usr/bin/env pwsh
# Consolidated prerequisite checking script (PowerShell)
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.ps1 [OPTIONS]
#
# OPTIONS:
# -Json Output in JSON format
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Help, -h Show help message
[CmdletBinding()]
param(
[switch]$Json,
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output @"
Usage: check-prerequisites.ps1 [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
-Json Output in JSON format
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
"@
exit 0
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths.
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
# the feature.json write side effect (issue #3025).
if ($PathsOnly) {
$paths = Get-FeaturePathsEnv -NoPersist
} else {
$paths = Get-FeaturePathsEnv
}
# If paths-only mode, output paths and exit (no validation)
if ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
BRANCH = $paths.CURRENT_BRANCH
FEATURE_DIR = $paths.FEATURE_DIR
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
}
exit 0
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
$specifyCommand = '/speckit-specify'
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = '/speckit-plan'
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
$tasksCommand = '/speckit-tasks'
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
exit 1
}
# Build list of available documents
$docs = @()
# Always check these optional docs
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}
}
+631
View File
@@ -0,0 +1,631 @@
#!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param([string]$StartDir = (Get-Location).Path)
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
$current = if ($resolved) { $resolved.Path } else { $null }
if (-not $current) { return $null }
while ($true) {
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
return $current
}
$parent = Split-Path $current -Parent
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
return $null
}
$current = $parent
}
}
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
# design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
param([switch]$ReturnNullOnError)
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
$initDir = Join-Path (Get-Location).Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
# Resolve-Path also succeeds for files, so check the resolved path is a
# directory; otherwise a file value would slip through to the less accurate
# "not a Spec Kit project" error below.
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
if ($ReturnNullOnError) { return $null }
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
# the returned root matches the bash resolver, whose `cd && pwd` never yields
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
# that already has no trailing separator.
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }
exit 1
}
return $initRoot
}
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
param([switch]$ReturnNullOnError)
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
}
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ($specifyRoot) {
return $specifyRoot
}
# Final fallback to script location
# Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# No explicit feature set - return empty to signal "unknown".
return ""
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
if ($onWin) {
$cmp = [System.StringComparison]::OrdinalIgnoreCase
} else {
$cmp = [System.StringComparison]::Ordinal
}
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
}
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
try {
$raw = Get-Content -LiteralPath $fjPath -Raw
$cfg = $raw | ConvertFrom-Json
if ($cfg.feature_directory -eq $FeatureDirectory) {
return
}
} catch {
# File is corrupt or unreadable - overwrite it
}
}
# Ensure .specify/ directory exists
$specifyDir = Join-Path $RepoRoot '.specify'
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
}
# Write feature.json
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
}
function Get-FeaturePathsEnv {
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
[switch]$NoPersist,
[switch]$ReturnNullOnError
)
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error - no feature context available
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
if (-not $NoPersist) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
if ($ReturnNullOnError) { return $null }
exit 1
}
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than
# an empty, misleading value (issue #3026).
if (-not $currentBranch) {
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
$featureDirTrimmed = $featureDir.TrimEnd('/', '\')
$currentBranch = Split-Path -Leaf $featureDirTrimmed
}
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output " [OK] $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
return $false
}
}
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
# A directory counts as non-empty when Get-ChildItem returns any entry
# (files or subdirectories) -- matching the JSON contracts checks in
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Write-Output " [OK] $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
return $false
}
}
function Get-InvokeSeparator {
param([string]$RepoRoot = (Get-RepoRoot))
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
$script:SpecKitInvokeSeparatorCache = @{}
}
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
}
$separator = '.'
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
try {
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
if ($key -and $state.integration_settings) {
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
if ($settingProperty) {
$setting = $settingProperty.Value
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
$separator = [string]$setting.invoke_separator
}
}
}
} catch {
$separator = '.'
}
}
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
return $separator
}
function Format-SpecKitCommand {
param(
[Parameter(Mandatory = $true)][string]$CommandName,
[string]$RepoRoot = (Get-RepoRoot)
)
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
$name = $CommandName.TrimStart('/')
if ($name.StartsWith('speckit.')) {
$name = $name.Substring(8)
} elseif ($name.StartsWith('speckit-')) {
$name = $name.Substring(8)
}
$name = $name -replace '\.', $separator
return "/speckit$separator$name"
}
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
if (Get-Command python -ErrorAction SilentlyContinue) {
$ver = & python --version 2>&1
if ($ver -match 'Python 3') { return @('python') }
}
if (Get-Command py -ErrorAction SilentlyContinue) {
$ver = & py -3 --version 2>&1
if ($ver -match 'Python 3') { return @('py', '-3') }
}
return $null
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) { return $priorityProperty.Value }
}
return 10
}
if ($presetEntries.Count -gt 1) {
$allNumeric = $true
$allStrings = $true
foreach ($entry in $presetEntries) {
$priority = & $priorityFor $entry
if ($null -eq $priority -or $priority -isnot [ValueType]) {
$allNumeric = $false
}
if ($null -eq $priority -or $priority -isnot [string]) {
$allStrings = $false
}
}
if (-not $allNumeric -and -not $allStrings) {
throw 'Registry priorities are not mutually orderable'
}
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { & $priorityFor $_ } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
$registryParsed = $false
}
}
if ($registryParsed) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) { return $core }
return $null
}
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
function Resolve-TemplateContent {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @()
$layerStrategies = @()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
$layerPaths += $override
$layerStrategies += 'replace'
}
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
} catch {
$sortedPresets = @()
}
}
if ($sortedPresets.Count -gt 0) {
$pyCmd = Get-Python3Command
if (-not $pyCmd) {
# Check if any preset has strategy fields that would be ignored
foreach ($pid in $sortedPresets) {
$mf = Join-Path $presetsDir "$pid/preset.yml"
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and $pyCmd) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
$pyStderrFile = [System.IO.Path]::GetTempFileName()
$stratResult = & $pyCmd[0] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(sys.argv[1]) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
"@ $manifest $TemplateName 2>$pyStderrFile
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 2
$strategy = $parts[0].ToLowerInvariant()
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
}
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ($manifestFilePath) {
# Reject absolute paths and parent traversal
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
$manifestFilePath = ''
}
}
if ($manifestFilePath) {
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) {
$layerPaths += $core
$layerStrategies += 'replace'
}
if ($layerPaths.Count -eq 0) { return $null }
# If the top (highest-priority) layer is replace, it wins entirely --
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return (Get-Content $layerPaths[0] -Raw)
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ($s in $layerStrategies) {
if ($s -ne 'replace') { $hasComposition = $true; break }
}
if (-not $hasComposition) {
return (Get-Content $layerPaths[0] -Raw)
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
if ($layerStrategies[$i] -eq 'replace') {
$baseIdx = $i
break
}
}
if ($baseIdx -lt 0) { return $null }
$content = Get-Content $layerPaths[$baseIdx] -Raw
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = Get-Content $path -Raw
switch ($strat) {
'replace' { $content = $layerContent }
'prepend' { $content = "$layerContent`n`n$content" }
'append' { $content = "$content`n`n$layerContent" }
'wrap' {
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
}
default { throw "Unknown strategy: $strat" }
}
}
return $content
}
@@ -0,0 +1,316 @@
#!/usr/bin/env pwsh
# Create a new feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[string]$Number = '',
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Examples:"
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
exit 0
}
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
# Validate description is not empty after trimming (e.g., user passed only whitespace)
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
}
return $highest
}
function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}
return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
# Use common.ps1 functions which prioritize .specify
$repoRoot = Get-RepoRoot
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
if (-not $DryRun) {
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
}
# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
# Common stop words to filter out
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
# Convert to lowercase and extract words (alphanumeric only)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
$meaningfulWords = @()
foreach ($word in $words) {
# Skip stop words
if ($stopWords -contains $word) { continue }
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Keep short words only if they appear as uppercase in original (likely
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
# bash script's case-sensitive grep; -match would be case-insensitive and
# would keep every short word.
$meaningfulWords += $word
}
}
# If we have meaningful words, use first 3-4 of them
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
# Fallback to original logic if no meaningful words found
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Generate branch name
if ($ShortName) {
# Use provided short name, just clean it up
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
# Generate from description with smart filtering
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Treat an explicit empty string as omitted, matching the bash and Python twins.
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
# Warn if -Number and -Timestamp are both specified.
if ($Timestamp -and $hasNumber) {
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
$Number = ''
}
# Determine branch prefix
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
[long]$resolvedNumber = 0
if (-not $hasNumber) {
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
if ($highestNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber = $highestNumber + 1
} elseif ($Number -notmatch '^[0-9]+$') {
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
exit 1
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
exit 1
}
$featureNum = ('{0:000}' -f $resolvedNumber)
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
}
$featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) {
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
if ($Timestamp) {
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
} else {
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
}
exit 1
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
# Match the bash twin (create-new-feature.sh): warn on stderr that no
# spec template was found before creating an empty spec file, so the
# missing-template signal is not silently swallowed on Windows.
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}
# Persist to .specify/feature.json so downstream commands can find the feature
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
[Console]::Error.WriteLine("# To persist: $featureAssignment")
[Console]::Error.WriteLine("# $directoryAssignment")
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "# To persist in your shell: $featureAssignment"
Write-Output "# $directoryAssignment"
}
}
@@ -0,0 +1,85 @@
#!/usr/bin/env pwsh
# Setup implementation plan for a feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help,
# Capture extra positional arguments to match Bash/Python behavior.
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
Write-Output " -Json Output results in JSON format"
Write-Output " -Help Show this help message"
exit 0
}
# Load common functions
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if plan doesn't already exist
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
if ($Json) {
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
} else {
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
}
} else {
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
if ($Json) {
[Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)")
} else {
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
}
} else {
# Match the bash twin's wording and stream routing (stderr in -Json so
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
# "Copied plan template" message above.
if ($Json) {
[Console]::Error.WriteLine("Warning: Plan template not found")
} else {
Write-Output "Warning: Plan template not found"
}
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
}
# Output results
if ($Json) {
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
}
$result | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
}
@@ -0,0 +1,82 @@
#!/usr/bin/env pwsh
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Help wins over unknown-argument validation to match the Bash/Python
# variants, which stop at --help and exit 0.
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
if ($RemainingArgs.Count -gt 0) {
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
exit 1
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = '/speckit-plan'
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
$specifyCommand = '/speckit-specify'
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
# Build available docs list
$docs = @()
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
# Output results
if ($Json) {
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
TASKS_TEMPLATE = $tasksTemplate
} | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
Write-Output "AVAILABLE_DOCS:"
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
}
+40
View File
@@ -0,0 +1,40 @@
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Purpose**: [Brief description of what this checklist covers]
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit-checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## [Category 1]
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## [Category 2]
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Notes
- Check items off as completed: `[x]`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference
@@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
+113
View File
@@ -0,0 +1,113 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit-plan command output)
├── research.md # Phase 0 output (/speckit-plan command)
├── data-model.md # Phase 1 output (/speckit-plan command)
├── quickstart.md # Phase 1 output (/speckit-plan command)
├── contracts/ # Phase 1 output (/speckit-plan command)
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
+131
View File
@@ -0,0 +1,131 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
*Example of marking unclear requirements:*
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
## Assumptions
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right assumptions based on reasonable defaults
chosen when the feature description did not specify certain details.
-->
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
+252
View File
@@ -0,0 +1,252 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit-tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
+77
View File
@@ -0,0 +1,77 @@
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
# 0.8.5 is the first release with engine-side resolution of the
# ``integration: "auto"`` default. Older versions would treat "auto"
# as a literal integration key and fail at dispatch.
speckit_version: ">=0.8.5"
integrations:
# The four commands below (specify, plan, tasks, implement) are core
# spec-kit commands provided by every integration. The list here is an
# advisory, non-exhaustive compatibility hint following the documented
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
# against any integration the project was initialized with, including
# ones not listed below, as long as that integration provides the four
# core commands referenced in ``steps``.
any:
- "claude"
- "copilot"
- "gemini"
- "opencode"
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
+13
View File
@@ -0,0 +1,13 @@
{
"schema_version": "1.0",
"workflows": {
"speckit": {
"name": "Full SDD Cycle",
"version": "1.0.0",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"source": "bundled",
"installed_at": "2026-07-27T03:19:04.090168+00:00",
"updated_at": "2026-07-27T03:19:04.090177+00:00"
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# Snap 백엔드 연결 — 진행 ledger
plan: docs/superpowers/plans/2026-07-18-snap-backend-connect.md
branch: feat/snap-backend-connect
merge-base(main): 997621822e551be36992a521a2dcf339b5d6ac6f
plan-committed HEAD: a205fbf
## Tasks
(완료 시 한 줄씩 append: "Task N: complete (commits <base7>..<head7>, review clean)")
Task 1: complete (commits 7e808b7..7e808b7, review clean)
Task 2: complete (commits 4b78e21..4b78e21, review clean)
Task 3: complete (commits 89f7015..89f7015, review clean; Minor: useCreateSession 테스트 공백 — 의도적, final review 에서 triage)
Task 4: complete (commits fa7b62c..fa7b62c, review clean)
Task 5: complete (commits 6a1f106..6a1f106, review clean)
Task 6: complete (commits c838ec5..c838ec5, review clean; Minor: test inline type 길이 — non-blocking)
Task 7: complete (commits 5b0b632..5b0b632, review clean)
Task 8: complete (commit e965b07, deletion verified: 4 mock files gone, tsc clean, snap tests pass, no dangling refs)
## 최종
- 모든 8 태스크 완료. 최종 whole-branch 리뷰(opus): MERGE WITH FIXES.
- Important 1(빈 assistant 버블 미제거) → fix 커밋 21adcf7 (snap 14/14).
- Important 2(세션 목록 10개 truncate) → 사용자 결정 "최근 10개 유지", 코드 주석만.
- Minor 3건 non-blocking.
- 전체 스위트 8 실패(authStore/MePage/themeStore)는 base a205fbf 에서도 동일 → 기존 baseline(zustand persist storage), 우리 브랜치 무관. 우리 surface(snap/streaming/api/auth) 전부 green + build ✓.
- 미커밋: abap 하이라이트 작업(CodeBlock/Message/abapHljs.ts/abapLight.css/package) — 이번 백엔드연결과 별개.
## 중단 UX (feat/snap-stop-ux) — 진행
plan-committed HEAD: 89dff3d
S1: complete (a7eeeff, cancelStream, 4/4, self-verified)
S2: complete (ae74eb2, isRevealing store, 9/9, self-verified)
S3: complete (ecaba5d, stop+retry+cancel, 7/7, self-verified)
S4: complete (1ba4e94, page wiring, tsc/lint/build ok, vitest 22/22, self-verified)
+19
View File
@@ -0,0 +1,19 @@
{
// 검색 오염 방지: junction(스킬 repo 전체가 딸려옴)·대용량 문서·생성물 제외
"search.exclude": {
"**/.claude/skills": true,
"**/docs-lib": true,
"**/.specify": true,
"**/dist": true,
"**/coverage": true
},
// 파일 감시도 같은 대상 제외 (junction 순환·불필요한 리로드 방지)
"files.watcherExclude": {
"**/.claude/skills/**": true,
"**/docs-lib/**": true,
"**/.specify/**": true,
"**/node_modules/**": true,
"**/dist/**": true
},
"files.encoding": "utf8"
}
+18
View File
@@ -0,0 +1,18 @@
# Node 빌드/캐시
node_modules/
dist/
dist-tsbuild/
tsconfig.tsbuildinfo
.vitest-cache/
# 테스트/E2E 결과물
e2e/
playwright-report/
test-results/
# Env
# Vite는 빌드 타임에 .env / .env.[mode]를 읽어 VITE_* 값을 번들에 박음.
# 따라서 빌드 컨테이너 안에 .env 가 있어야 함. (VITE_* 는 정의상 public)
# 개발자 머신 전용 오버라이드(.local)만 image에서 제외.
.env.local
.env.*.local
+9
View File
@@ -0,0 +1,9 @@
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
VITE_API_BASE_URL=/api/v1
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
VITE_DEV_API_TARGET=http://localhost:8001
# Entra Graph scope (선택, 기본값: User.Read)
# VITE_ENTRA_GRAPH_SCOPE=User.Read
+9
View File
@@ -0,0 +1,9 @@
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
VITE_API_BASE_URL=/api/v1
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
VITE_DEV_API_TARGET=http://localhost:8001
# Entra Graph scope (선택, 기본값: User.Read)
# VITE_ENTRA_GRAPH_SCOPE=User.Read
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
dist-ssr
dist-tsbuild
*.local
.DS_Store
.idea
coverage
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
coverage
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
+47
View File
@@ -0,0 +1,47 @@
# 2_frontend — React SPA 템플릿
Vite + React + TypeScript + Tailwind + shadcn/ui + Zustand + TanStack Query 기반.
백엔드(`1_backend/`, FastAPI + SSE + JWT)와 연동.
## 시작하기
```bash
npm install
cp .env.example .env
npm run dev # http://localhost:5173
```
백엔드도 같이 띄울 것: `cd ../1_backend && uv run uvicorn main:app --reload --reload-dir src --port 8001 --app-dir src`
## 주요 명령
| 명령 | 설명 |
| ---------------- | ------------------ |
| `npm run dev` | dev 서버 (5173) |
| `npm run build` | 프로덕션 빌드 |
| `npm run lint` | ESLint |
| `npm run test` | Vitest 단위 테스트 |
| `npm run format` | Prettier 포맷 |
자세한 설계: `../docs/superpowers/specs/2026-05-05-frontend-template-design.md`
## 검증 완료 (2026-05-05)
- 부트스트랩 (Vite + TS + Tailwind + ESLint + Prettier 설정)
- core lib (tokenStore, api client + 401 refresh, SSE wrapper, queryClient, cn)
- shared (shadcn/ui core, Layout, ProtectedRoute, ErrorBoundary)
- features/auth (schemas, store, api, hooks, components, pages)
- features/users (api, hooks, MePage)
- features/chat (store, SSE wrapper, hook, components, ChatPage)
- routing + Provider 와이어업
테스트 34개 통과, lint 깨끗, build 성공.
수동 시나리오 검증 (백엔드 8001 + 프론트 5173 같이 띄우고):
1. `/` → ProtectedRoute가 `/login` 으로 redirect
2. `happy@pwc.com / a1234` 로그인 → `/chat` 이동
3. 헤더에 이메일 보임
4. `/me` 클릭 → 내 정보 표시
5. `/chat` 메시지 보내면 SSE echo로 토큰 단위 누적
6. Logout → `/login` 이동
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared",
"utils": "@/lib/utils/cn",
"ui": "@/shared/ui",
"lib": "@/lib",
"hooks": "@/lib/hooks"
},
"iconLibrary": "lucide"
}
+17
View File
@@ -0,0 +1,17 @@
# docs-lib
이 모듈(2_frontend)이 쓰는 라이브러리 참조 문서 모음. 구현 전에 여기 먼저 보고 감 — 기억으로 API 쓰지 말 것. 파일이 크면 통째로 읽지 말고 Grep 으로 필요한 부분만 꺼내 쓰기.
## 카탈로그
| 파일 | 라이브러리 | 출처 | 갱신법 |
| ------------------- | ----------------------- | ------------------------------------------------------------------------------- | ------------------------------------ |
| `react-markdown.md` | react-markdown v10 | https://raw.githubusercontent.com/remarkjs/react-markdown/main/readme.md | curl 로 다시 받기 |
| `tauri-api.md` | @tauri-apps/api v2.11.1 | **패키지 실물** `node_modules/@tauri-apps/api/*.d.ts`·`*.js` 에서 직접 확인 | 버전 올라가면 `.d.ts` 다시 보고 갱신 |
| `tanstack-query.md` | TanStack Query v5 | https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries | 공식 문서에서 필요한 부분 갱신 |
| `axios.md` | Axios v1 | https://axios-http.com/docs/interceptors | 공식 문서에서 필요한 부분 갱신 |
| `react-router.md` | React Router | https://reactrouter.com/api/hooks/useRoutes | 공식 문서에서 필요한 부분 갱신 |
## 갱신법
출처 URL 을 curl 로 다시 받아 덮어쓰면 됨. 새 라이브러리 문서 추가하면 위 표에 한 줄 등록.
+17
View File
@@ -0,0 +1,17 @@
# Axios v1 인터셉터 참조
출처: https://axios-http.com/docs/interceptors
확인일: 2026-09-11
## 응답 인터셉터
2xx 밖 응답은 response interceptor의 reject handler로 들어옴. 오류를 처리하지 않을 때는 reject/throw로 다음 호출자에게 전달해야 함.
```ts
instance.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error)
)
```
여러 response interceptor는 등록 순서(FIFO)로 실행됨.
+879
View File
@@ -0,0 +1,879 @@
<!--
Notes for maintaining this document:
* update the version of the link for `commonmark-html` once in a while
-->
# react-markdown
[![Build][badge-build-image]][badge-build-url]
[![Coverage][badge-coverage-image]][badge-coverage-url]
[![Downloads][badge-downloads-image]][badge-downloads-url]
[![Size][badge-size-image]][badge-size-url]
React component to render markdown.
## Feature highlights
- [x] **[safe][section-security] by default**
(no `dangerouslySetInnerHTML` or XSS attacks)
- [x] **[components][section-components]**
(pass your own component to use instead of `<h2>` for `## hi`)
- [x] **[plugins][section-plugins]**
(many plugins you can pick and choose from)
- [x] **[compliant][section-syntax]**
(100% to CommonMark, 100% to GFM with a plugin)
## Contents
- [What is this?](#what-is-this)
- [When should I use this?](#when-should-i-use-this)
- [Install](#install)
- [Use](#use)
- [API](#api)
- [`Markdown`](#markdown)
- [`MarkdownAsync`](#markdownasync)
- [`MarkdownHooks`](#markdownhooks)
- [`defaultUrlTransform(url)`](#defaulturltransformurl)
- [`AllowElement`](#allowelement)
- [`Components`](#components)
- [`ExtraProps`](#extraprops)
- [`HooksOptions`](#hooksoptions)
- [`Options`](#options)
- [`UrlTransform`](#urltransform)
- [Examples](#examples)
- [Use a plugin](#use-a-plugin)
- [Use a plugin with options](#use-a-plugin-with-options)
- [Use custom components (syntax highlight)](#use-custom-components-syntax-highlight)
- [Use remark and rehype plugins (math)](#use-remark-and-rehype-plugins-math)
- [Plugins](#plugins)
- [Syntax](#syntax)
- [Compatibility](#compatibility)
- [Architecture](#architecture)
- [Appendix A: HTML in markdown](#appendix-a-html-in-markdown)
- [Appendix B: Components](#appendix-b-components)
- [Appendix C: line endings in markdown (and JSX)](#appendix-c-line-endings-in-markdown-and-jsx)
- [Security](#security)
- [Related](#related)
- [Contribute](#contribute)
- [License](#license)
## What is this?
This package is a [React][] component that can be given a string of markdown
that itll safely render to React elements.
You can pass plugins to change how markdown is transformed and pass components
that will be used instead of normal HTML elements.
- to learn markdown, see this [cheatsheet and tutorial][commonmark-help]
- to try out `react-markdown`, see [our demo][github-io-react-markdown]
## When should I use this?
There are other ways to use markdown in React out there so why use this one?
The three main reasons are that they often rely on `dangerouslySetInnerHTML`,
have bugs with how they handle markdown, or dont let you swap elements for
components.
`react-markdown` builds a virtual DOM, so React only replaces what changed,
from a syntax tree.
Thats supported because we use [unified][github-unified],
specifically [remark][github-remark] for markdown and [rehype][github-rehype]
for HTML,
which are popular tools to transform content with plugins.
This package focusses on making it easy for beginners to safely use markdown in
React.
When youre familiar with unified, you can use a modern hooks based alternative
[`react-remark`][github-react-remark] or [`rehype-react`][github-rehype-react]
manually.
If you instead want to use JavaScript and JSX _inside_ markdown files, use
[MDX][github-mdx].
## Install
This package is [ESM only][esm].
In Node.js (version 16+), install with [npm][npm-install]:
```sh
npm install react-markdown
```
In Deno with [`esm.sh`][esmsh]:
```js
import Markdown from "https://esm.sh/react-markdown@10"
```
In browsers with [`esm.sh`][esmsh]:
```html
<script type="module">
import Markdown from "https://esm.sh/react-markdown@10?bundle"
</script>
```
## Use
A basic hello world:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
const markdown = "# Hi, *Pluto*!"
createRoot(document.body).render(<Markdown>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<h1>
Hi, <em>Pluto</em>!
</h1>
```
</details>
Here is an example that shows how to use a plugin
([`remark-gfm`][github-remark-gfm],
which adds support for footnotes, strikethrough, tables, tasklists and
URLs directly):
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = `Just a link: www.nasa.gov.`
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
Just a link: <a href="http://www.nasa.gov">www.nasa.gov</a>.
</p>
```
</details>
## API
This package exports the identifiers
[`MarkdownAsync`][api-markdown-async],
[`MarkdownHooks`][api-markdown-hooks],
and
[`defaultUrlTransform`][api-default-url-transform].
The default export is [`Markdown`][api-markdown].
It also exports the additional [TypeScript][] types
[`AllowElement`][api-allow-element],
[`Components`][api-components],
[`ExtraProps`][api-extra-props],
[`HooksOptions`][api-hooks-options],
[`Options`][api-options],
and
[`UrlTransform`][api-url-transform].
### `Markdown`
Component to render markdown.
This is a synchronous component.
When using async plugins,
see [`MarkdownAsync`][api-markdown-async] or
[`MarkdownHooks`][api-markdown-hooks].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
React element (`ReactElement`).
### `MarkdownAsync`
Component to render markdown with support for async plugins
through async/await.
Components returning promises are supported on the server.
For async support on the client,
see [`MarkdownHooks`][api-markdown-hooks].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
Promise to a React element (`Promise<ReactElement>`).
### `MarkdownHooks`
Component to render markdown with support for async plugins through hooks.
This uses `useEffect` and `useState` hooks.
Hooks run on the client and do not immediately render something.
For async support on the server,
see [`MarkdownAsync`][api-markdown-async].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
React node (`ReactNode`).
### `defaultUrlTransform(url)`
Make a URL safe.
This follows how GitHub works.
It allows the protocols `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp`,
and URLs relative to the current protocol (such as `/something`).
###### Parameters
- `url` (`string`)
— URL
###### Returns
Safe URL (`string`).
### `AllowElement`
Filter elements (TypeScript type).
###### Parameters
- `node` ([`Element` from `hast`][github-hast-element])
— element to check
- `index` (`number | undefined`)
— index of `element` in `parent`
- `parent` ([`Node` from `hast`][github-hast-nodes])
— parent of `element`
###### Returns
Whether to allow `element` (`boolean`, optional).
### `Components`
Map tag names to components (TypeScript type).
###### Type
```ts
import type { ExtraProps } from "react-markdown"
import type { ComponentProps, ElementType } from "react"
type Components = {
[Key in Extract<ElementType, string>]?: ElementType<ComponentProps<Key> & ExtraProps>
}
```
### `ExtraProps`
Extra fields we pass to components (TypeScript type).
###### Fields
- `node` ([`Element` from `hast`][github-hast-element], optional)
— original node
### `HooksOptions`
Configuration for [`MarkdownHooks`][api-markdown-hooks] (TypeScript type);
extends the regular [`Options`][api-options] with a `fallback` prop.
###### Extends
[`Options`][api-options].
###### Fields
- `fallback` (`ReactNode`, optional)
— content to render while the processor processing the markdown
### `Options`
Configuration (TypeScript type).
###### Fields
- `allowElement` ([`AllowElement`][api-allow-element], optional)
— filter elements;
`allowedElements` / `disallowedElements` is used first
- `allowedElements` (`Array<string>`, default: all tag names)
— tag names to allow;
cannot combine w/ `disallowedElements`
- `children` (`string`, optional)
— markdown
- `components` ([`Components`][api-components], optional)
— map tag names to components
- `disallowedElements` (`Array<string>`, default: `[]`)
— tag names to disallow;
cannot combine w/ `allowedElements`
- `rehypePlugins` (`Array<Plugin>`, optional)
— list of [rehype plugins][github-rehype-plugins] to use
- `remarkPlugins` (`Array<Plugin>`, optional)
— list of [remark plugins][github-remark-plugins] to use
- `remarkRehypeOptions`
([`Options` from `remark-rehype`][github-remark-rehype-options],
optional)
— options to pass through to `remark-rehype`
- `skipHtml` (`boolean`, default: `false`)
— ignore HTML in markdown completely
- `unwrapDisallowed` (`boolean`, default: `false`)
— extract (unwrap) whats in disallowed elements;
normally when say `strong` is not allowed, it and its children are dropped,
with `unwrapDisallowed` the element itself is replaced by its children
- `urlTransform` ([`UrlTransform`][api-url-transform], default:
[`defaultUrlTransform`][api-default-url-transform])
— change URLs
### `UrlTransform`
Transform URLs (TypeScript type).
###### Parameters
- `url` (`string`)
— URL
- `key` (`string`, example: `'href'`)
— property name
- `node` ([`Element` from `hast`][github-hast-element])
— element to check
###### Returns
Transformed URL (`string`, optional).
## Examples
### Use a plugin
This example shows how to use a remark plugin.
In this case, [`remark-gfm`][github-remark-gfm],
which adds support for strikethrough, tables, tasklists and URLs directly:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = `A paragraph with *emphasis* and **strong importance**.
> A block quote with ~strikethrough~ and a URL: https://reactjs.org.
* Lists
* [ ] todo
* [x] done
A table:
| a | b |
| - | - |
`
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<>
<p>
A paragraph with <em>emphasis</em> and <strong>strong importance</strong>.
</p>
<blockquote>
<p>
A block quote with <del>strikethrough</del> and a URL:{" "}
<a href="https://reactjs.org">https://reactjs.org</a>.
</p>
</blockquote>
<ul className="contains-task-list">
<li>Lists</li>
<li className="task-list-item">
<input type="checkbox" disabled /> todo
</li>
<li className="task-list-item">
<input type="checkbox" disabled checked /> done
</li>
</ul>
<p>A table:</p>
<table>
<thead>
<tr>
<th>a</th>
<th>b</th>
</tr>
</thead>
</table>
</>
```
</details>
### Use a plugin with options
This example shows how to use a plugin and give it options.
To do that, use an array with the plugin at the first place, and the options
second.
[`remark-gfm`][github-remark-gfm] has an option to allow only double tildes for
strikethrough:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = "This ~is not~ strikethrough, but ~~this is~~!"
createRoot(document.body).render(
<Markdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>{markdown}</Markdown>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
This ~is not~ strikethrough, but <del>this is</del>!
</p>
```
</details>
### Use custom components (syntax highlight)
This example shows how you can overwrite the normal handling of an element by
passing a component.
In this case, we apply syntax highlighting with the seriously super amazing
[`react-syntax-highlighter`][github-react-syntax-highlighter] by
[**@conorhastings**][github-conorhastings]:
<!-- To do: currently broken on actual ESM; lets find an alternative? -->
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism"
// Did you know you can use tildes instead of backticks for code in markdown? ✨
const markdown = `Here is some JavaScript code:
~~~js
console.log('It works!')
~~~
`
createRoot(document.body).render(
<Markdown
children={markdown}
components={{
code(props) {
const { children, className, node, ...rest } = props
const match = /language-(\w+)/.exec(className || "")
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={dark}
/>
) : (
<code {...rest} className={className}>
{children}
</code>
)
},
}}
/>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<>
<p>Here is some JavaScript code:</p>
<pre>
<SyntaxHighlighter
language="js"
style={dark}
PreTag="div"
children="console.log('It works!')"
/>
</pre>
</>
```
</details>
### Use remark and rehype plugins (math)
This example shows how a syntax extension
(through [`remark-math`][github-remark-math])
is used to support math in markdown, and a transform plugin
([`rehype-katex`][github-rehype-katex]) to render that math.
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import rehypeKatex from "rehype-katex"
import remarkMath from "remark-math"
import "katex/dist/katex.min.css" // `rehype-katex` does not import the CSS for you
const markdown = `The lift coefficient ($C_L$) is a dimensionless coefficient.`
createRoot(document.body).render(
<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{markdown}
</Markdown>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
The lift coefficient (
<span className="katex">
<span className="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">{/* … */}</math>
</span>
<span className="katex-html" aria-hidden="true">
{/* … */}
</span>
</span>
) is a dimensionless coefficient.
</p>
```
</details>
## Plugins
We use [unified][github-unified],
specifically [remark][github-remark] for markdown and
[rehype][github-rehype] for HTML,
which are tools to transform content with plugins.
Here are three good ways to find plugins:
- [`awesome-remark`][github-awesome-remark] and
[`awesome-rehype`][github-awesome-rehype]
— selection of the most awesome projects
- [List of remark plugins][github-remark-plugins] and
[list of rehype plugins][github-rehype-plugins]
— list of all plugins
- [`remark-plugin`][github-topic-remark-plugin] and
[`rehype-plugin`][github-topic-rehype-plugin] topics
— any tagged repo on GitHub
## Syntax
`react-markdown` follows CommonMark, which standardizes the differences between
markdown implementations, by default.
Some syntax extensions are supported through plugins.
We use [`micromark`][github-micromark] under the hood for our parsing.
See its documentation for more information on markdown, CommonMark, and
extensions.
## Compatibility
Projects maintained by the unified collective are compatible with maintained
versions of Node.js.
When we cut a new major release, we drop support for unmaintained versions of
Node.
This means we try to keep the current release line, `react-markdown@10`,
compatible with Node.js 16.
They work in all modern browsers (essentially: everything not IE 11).
You can use a bundler (such as esbuild, webpack, or Rollup) to use this package
in your project, and use its options (or plugins) to add support for legacy
browsers.
## Architecture
<pre><code> react-markdown
+----------------------------------------------------------------------------------------------------------------+
| |
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
| | | | | | | | | | | |
<a href="https://commonmark.org">markdown</a>-+->+ <a href="https://github.com/remarkjs/remark">remark</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark/blob/main/doc/plugins.md">remark plugins</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark-rehype">remark-rehype</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="https://github.com/rehypejs/rehype/blob/main/doc/plugins.md">rehype plugins</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="#appendix-b-components">components</a> +-+->react elements
| | | | | | | | | | | |
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
| |
+----------------------------------------------------------------------------------------------------------------+
</code></pre>
To understand what this project does, its important to first understand what
unified does: please read through the [`unifiedjs/unified`][github-unified]
readme
(the part until you hit the API section is required reading).
`react-markdown` is a unified pipeline — wrapped so that most folks dont need
to directly interact with unified.
The processor goes through these steps:
- parse markdown to mdast (markdown syntax tree)
- transform through remark (markdown ecosystem)
- transform mdast to hast (HTML syntax tree)
- transform through rehype (HTML ecosystem)
- render hast to React with components
## Appendix A: HTML in markdown
`react-markdown` typically escapes HTML (or ignores it, with `skipHtml`)
because it is dangerous and defeats the purpose of this library.
However, if you are in a trusted environment (you trust the markdown), and
can spare the bundle size (±60kb minzipped), then you can use
[`rehype-raw`][github-rehype-raw]:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import rehypeRaw from "rehype-raw"
const markdown = `<div class="note">
Some *emphasis* and <strong>strong</strong>!
</div>`
createRoot(document.body).render(<Markdown rehypePlugins={[rehypeRaw]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<div className="note">
<p>
Some <em>emphasis</em> and <strong>strong</strong>!
</p>
</div>
```
</details>
**Note**: HTML in markdown is still bound by how [HTML works in
CommonMark][commonmark-html].
Make sure to use blank lines around block-level HTML that again contains
markdown!
## Appendix B: Components
You can also change the things that come from markdown:
```js
<Markdown
components={{
// Map `h1` (`# heading`) to use `h2`s.
h1: "h2",
// Rewrite `em`s (`*like so*`) to `i` with a red foreground color.
em(props) {
const { node, ...rest } = props
return <i style={{ color: "red" }} {...rest} />
},
}}
/>
```
The keys in components are HTML equivalents for the things you write with
markdown (such as `h1` for `# heading`).
Normally, in markdown, those are: `a`, `blockquote`, `br`, `code`, `em`, `h1`,
`h2`, `h3`, `h4`, `h5`, `h6`, `hr`, `img`, `li`, `ol`, `p`, `pre`, `strong`, and
`ul`.
With [`remark-gfm`][github-remark-gfm],
you can also use `del`, `input`, `table`, `tbody`, `td`, `th`, `thead`, and `tr`.
Other remark or rehype plugins that add support for new constructs will also
work with `react-markdown`.
The props that are passed are what you probably would expect: an `a` (link) will
get `href` (and `title`) props, and `img` (image) an `src`, `alt` and `title`,
etc.
Every component will receive a `node`.
This is the original [`Element` from `hast`][github-hast-element] element being
turned into a React element.
## Appendix C: line endings in markdown (and JSX)
You might have trouble with how line endings work in markdown and JSX.
We recommend the following, which solves all line ending problems:
```js
// If you write actual markdown in your code, put your markdown in a variable;
// **do not indent markdown**:
const markdown = `
# This is perfect!
`
// Pass the value as an expression as an only child:
const result = <Markdown>{markdown}</Markdown>
```
👆 That works.
Read on for what doesnt and why that is.
You might try to write markdown directly in your JSX and find that it **does
not** work:
```js
<Markdown># Hi This is **not** a paragraph.</Markdown>
```
The is because in JSX the whitespace (including line endings) is collapsed to
a single space.
So the above example is equivalent to:
```js
<Markdown> # Hi This is **not** a paragraph. </Markdown>
```
Instead, to pass markdown to `Markdown`, you can use an expression:
with a template literal:
```js
<Markdown>{`
# Hi
This is a paragraph.
`}</Markdown>
```
Template literals have another potential problem, because they keep whitespace
(including indentation) inside them.
That means that the following **does not** turn into a heading:
```js
<Markdown>{`
# This is **not** a heading, its an indented code block
`}</Markdown>
```
## Security
Use of `react-markdown` is secure by default.
Overwriting `urlTransform` to something insecure will open you up to XSS
vectors.
Furthermore, the `remarkPlugins`, `rehypePlugins`, and `components` you use may
be insecure.
To make sure the content is completely safe, even after what plugins do,
use [`rehype-sanitize`][github-rehype-sanitize].
It lets you define your own schema of what is and isnt allowed.
## Related
- [`MDX`][github-mdx]
— JSX _in_ markdown
- [`remark-gfm`][github-remark-gfm]
— add support for GitHub flavored markdown support
- [`react-remark`][github-react-remark]
— hook based alternative
- [`rehype-react`][github-rehype-react]
— turn HTML into React elements
## Contribute
See [`contributing.md`][health-contributing] in [`remarkjs/.github`][health]
for ways to get started.
See [`support.md`][health-support] for ways to get help.
This project has a [code of conduct][health-coc].
By interacting with this repository, organization, or community you agree to
abide by its terms.
## License
[MIT][file-license] © [Espen Hovlandsdal][author]
[api-allow-element]: #allowelement
[api-components]: #components
[api-default-url-transform]: #defaulturltransformurl
[api-extra-props]: #extraprops
[api-hooks-options]: #hooksoptions
[api-markdown]: #markdown
[api-markdown-async]: #markdownasync
[api-markdown-hooks]: #markdownhooks
[api-options]: #options
[api-url-transform]: #urltransform
[author]: https://espen.codes/
[badge-build-image]: https://github.com/remarkjs/react-markdown/workflows/main/badge.svg
[badge-build-url]: https://github.com/remarkjs/react-markdown/actions
[badge-coverage-image]: https://img.shields.io/codecov/c/github/remarkjs/react-markdown.svg
[badge-coverage-url]: https://codecov.io/github/remarkjs/react-markdown
[badge-downloads-image]: https://img.shields.io/npm/dm/react-markdown.svg
[badge-downloads-url]: https://www.npmjs.com/package/react-markdown
[badge-size-image]: https://img.shields.io/bundlejs/size/react-markdown
[badge-size-url]: https://bundlejs.com/?q=react-markdown
[commonmark-help]: https://commonmark.org/help/
[commonmark-html]: https://spec.commonmark.org/0.31.2/#html-blocks
[esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
[esmsh]: https://esm.sh
[file-license]: license
[github-awesome-rehype]: https://github.com/rehypejs/awesome-rehype
[github-awesome-remark]: https://github.com/remarkjs/awesome-remark
[github-conorhastings]: https://github.com/conorhastings
[github-hast-element]: https://github.com/syntax-tree/hast#element
[github-hast-nodes]: https://github.com/syntax-tree/hast#nodes
[github-io-react-markdown]: https://remarkjs.github.io/react-markdown/
[github-mdx]: https://github.com/mdx-js/mdx/
[github-micromark]: https://github.com/micromark/micromark
[github-react-remark]: https://github.com/remarkjs/react-remark
[github-react-syntax-highlighter]: https://github.com/react-syntax-highlighter/react-syntax-highlighter
[github-rehype]: https://github.com/rehypejs/rehype
[github-rehype-katex]: https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex
[github-rehype-plugins]: https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins
[github-rehype-raw]: https://github.com/rehypejs/rehype-raw
[github-rehype-react]: https://github.com/rehypejs/rehype-react
[github-rehype-sanitize]: https://github.com/rehypejs/rehype-sanitize
[github-remark]: https://github.com/remarkjs/remark
[github-remark-gfm]: https://github.com/remarkjs/remark-gfm
[github-remark-math]: https://github.com/remarkjs/remark-math
[github-remark-plugins]: https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins
[github-remark-rehype-options]: https://github.com/remarkjs/remark-rehype#options
[github-topic-rehype-plugin]: https://github.com/topics/rehype-plugin
[github-topic-remark-plugin]: https://github.com/topics/remark-plugin
[github-unified]: https://github.com/unifiedjs/unified
[health]: https://github.com/remarkjs/.github
[health-coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md
[health-contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md
[health-support]: https://github.com/remarkjs/.github/blob/main/support.md
[npm-install]: https://docs.npmjs.com/cli/install
[react]: http://reactjs.org
[section-components]: #appendix-b-components
[section-plugins]: #plugins
[section-security]: #security
[section-syntax]: #syntax
[typescript]: https://www.typescriptlang.org
+10
View File
@@ -0,0 +1,10 @@
# React Router route 정리 참고
출처: https://reactrouter.com/api/hooks/useRoutes
- `useRoutes(routes)``RouteObject[]`로 route 트리를 만들고 현재 URL과 맞는 element를 반환함.
- 부모 route의 `children`은 중첩 UI를 구성하며 부모 element의 `Outlet` 위치에 렌더됨.
- 선언한 route와 맞지 않는 URL은 마지막 `path: "*"` route에서 `Navigate`로 기본 화면에 보낼 수 있음.
- `Navigate``replace`는 잘못된 URL을 브라우저 방문 기록에 남기지 않을 때 사용함.
이 프로젝트에서는 `routes.tsx`가 유일한 route 트리이며, `PATHS`가 주소 문자열의 진실원천임.
+18
View File
@@ -0,0 +1,18 @@
# TanStack Query v5 참조
출처: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries
확인일: 2026-09-11
## enabled와 초기 로딩
- `enabled: false`이고 캐시가 없으면 `status === "pending"`, `fetchStatus === "idle"`임.
- `isLoading``isPending && isFetching`이라 실제 첫 fetch 중일 때만 참임.
- 조건이 충족된 뒤 자동 fetch해야 하면 `enabled`에 조건을 넘김.
```tsx
const query = useQuery({
queryKey: ["todos", filter],
queryFn: () => fetchTodos(filter),
enabled: Boolean(filter),
})
```
+192
View File
@@ -0,0 +1,192 @@
# @tauri-apps/api v2 — 프론트가 쓸 표면 박제
**작성**: 2026-08-15 | **근거**: `specs/004-tauri-shell/research.md` R3
**여기 적힌 건 전부 `node_modules/@tauri-apps/api/` 의 실제 `.d.ts`·`.js` 에서 뽑은 것이다.** 공식 가이드 예제는 옛 버전이 섞여 있어 시그니처 근거로 안 쓴다.
**박제 기준 버전: 2.11.1** (`package.json``^2.11.1`)
우리가 쓰는 건 딱 두 개 — `invoke`(JS→Rust)와 `listen`(Rust→JS). 나머지 모듈(`window`, `menu`, `tray`, `path` …)은 **안 쓴다.** 창·트레이·핫키는 전부 Rust 쪽에서 하고, 프론트는 우리가 만든 `#[tauri::command]` 만 부른다.
---
## 1. `invoke` — JS → Rust
```ts
// node_modules/@tauri-apps/api/core.d.ts:127
declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>
// core.d.ts:105
type InvokeArgs = Record<string, unknown> | number[] | ArrayBuffer | Uint8Array
```
```ts
import { invoke } from "@tauri-apps/api/core"
await invoke<Snippet[]>("snippets_list")
await invoke("paste_code", { text })
```
- **`Promise` 를 돌려준다** → 요청↔응답 짝맞춤이 공짜. `snippetBridge.ts``reqId`·`pending` Map 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (research R6)
- Rust 커맨드가 `Err(String)` 을 주면 **그 문자열로 reject** 된다 → 한글 오류 메시지가 그대로 `Error` 로 올라와 sonner 토스트까지 흐름 (`contracts/transport-mapping.md` §3)
- 인자 이름은 Rust 커맨드의 파라미터 이름과 맞아야 한다 (`paste_code(text: String)``{ text }`)
### 속을 보면
```js
// core.js:201
async function invoke(cmd, args = {}, options) {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}
```
**`window.__TAURI_INTERNALS__.invoke` 를 그대로 부른다.** 이게 아래 호스트 판별에서 중요해진다.
---
## 2. `listen` — Rust → JS
```ts
// event.d.ts:87
declare function listen<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options
): Promise<UnlistenFn>
// event.d.ts:25,33,34
interface Event<T> {
event: string
id: number
payload: T
}
type EventCallback<T> = (event: Event<T>) => void
type UnlistenFn = () => void
```
```ts
import { listen } from "@tauri-apps/api/event"
const unlisten = await listen<BridgeMessage>("bridge", (e) => {
handle(e.payload) // ← 페이로드는 e.payload 안에 있다
})
```
### ⚠️ 함정 2개
**(1) 페이로드가 한 겹 더 들어있다.** WebView2 는 `event.data` 가 곧 메시지인데, Tauri 는 `Event<T>` 로 감싸서 `event.payload` 안에 있다. `bridgeNavigate.ts` 의 분기 본문을 그대로 재사용하려면 **transport 층에서 `e.payload` 를 벗겨서** 넘겨야 한다 (research R5 가 노린 "분기 로직 무변경"이 이 한 줄에 달림).
**(2) `listen` 은 async 다.** `Promise<UnlistenFn>` 을 돌려준다.
지금 `bridgeNavigate.ts``initBridgeNavigate()` 는 **동기 함수**고, WebView2 의 `addEventListener` 는 즉시 붙는다. Tauri 경로는 그렇지 않아서:
- `initBridgeNavigate()` 를 async 로 바꾸면 **호출부(앱 루트)가 바뀐다** → SC-001("통로 파일 바깥 변경 0줄") 위반
- 그래서 **transport 안에서 Promise 를 삼키고**(fire-and-forget) 밖에는 동기 시그니처를 유지하는 쪽이 맞다
- 대신 **리스너 붙기 전에 도착한 푸시는 놓친다.** 다행히 이 코드에는 이미 대비가 있다 — `lastPasteTarget` 스냅샷과 `pendingCaptureImage` 의 read-once 소비가 마운트 레이스를 막으려고 들어간 것이라, 같은 장치가 여기서도 먹는다
관련: `once`(1회), `emit`/`emitTo`(JS→Rust 이벤트) 도 있지만 **우리는 안 쓴다.** 계약상 JS→Rust 는 전부 `invoke` (transport-mapping §1).
---
## 3. 호스트 판별 — `isTauri()` 를 쓰지 말 것
패키지가 공식 함수를 하나 주긴 한다:
```js
// core.js:278
function isTauri() {
return !!(globalThis || window).isTauri
}
```
**이건 `window.isTauri` 라는 별개 플래그를 볼 뿐, `invoke` 가 실제로 쓰는 `__TAURI_INTERNALS__` 와 다르다.**
research R4 가 정한 `__TAURI_INTERNALS__` 기준이 맞다 — `invoke` 가 바로 그걸 부르므로, **"invoke 를 부를 수 있는가"를 직접 재는 셈**이라 한 단계 더 정확하다.
### 그런데 `"__TAURI_INTERNALS__" in window` 도 부족하다 ⚠️
테스트용 `clearMocks()` 를 뜯어보면:
```js
// mocks.js:4 — mockIPC 가 부르는 것
function mockInternals() {
window.__TAURI_INTERNALS__ = window.__TAURI_INTERNALS__ ?? {}
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = window.__TAURI_EVENT_PLUGIN_INTERNALS__ ?? {}
}
// mocks.js:267 — clearMocks
function clearMocks() {
if (typeof window.__TAURI_INTERNALS__ !== "object") return
delete window.__TAURI_INTERNALS__.invoke
delete window.__TAURI_INTERNALS__.transformCallback
// ... 속성만 지운다. window.__TAURI_INTERNALS__ 객체 자체는 안 지움
}
```
**`clearMocks()` 는 속성만 지우고 빈 객체를 남긴다.** 그래서:
| 판별식 | mockIPC 후 | clearMocks 후 | 판정 |
| ---------------------------------------------------------- | ---------- | ---------------- | ----------------------------------------- |
| `"__TAURI_INTERNALS__" in window` | true | **true (틀림)** | ❌ 브라우저 모드 테스트가 tauri 로 오판됨 |
| `typeof window.__TAURI_INTERNALS__?.invoke === "function"` | true | **false (맞음)** | ✅ |
`in` 으로 재면 "브라우저에서는 조용히 no-op / 즉시 reject"(FR-003) 테스트가 **tauri 경로를 타서 `__TAURI_INTERNALS__.invoke is not a function` 으로 터진다.** 우리가 의도한 한글 "데스크톱 전용" reject 가 아니라 엉뚱한 TypeError.
**결론: `.invoke` 가 함수인지로 판별한다.** 실제 앱에서도 Tauri 가 앱 스크립트보다 먼저 내부 객체를 통째로 주입하므로 안전하고, 의미도 더 정확하다("통로가 있나"가 아니라 "통로가 작동하나").
---
## 4. 테스트 — 공식 mock 이 있다
```ts
// mocks.d.ts
export declare function mockIPC(
cb: (cmd: string, payload?: InvokeArgs) => unknown,
options?: MockIPCOptions
): void
export declare function clearMocks(): void
export declare function mockWindows(current: string, ..._additionalWindows: string[]): void
export declare function mockConvertFileSrc(osName: string): void
export interface MockIPCOptions {
shouldMockEvents?: boolean
} // 2.7.0+
```
```ts
import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"
afterEach(() => clearMocks())
it("snippets_list 를 부른다", async () => {
mockIPC((cmd) => (cmd === "snippets_list" ? [] : undefined))
await expect(snippetsApi.list()).resolves.toEqual([])
})
```
**`shouldMockEvents: true` 를 주면 `listen`/`emit` 도 mock 된다** — Rust 푸시(`emit("bridge", ...)`)를 흉내내 `bridgeNavigate` 분기를 테스트할 수 있다.
```ts
mockIPC(() => {}, { shouldMockEvents: true })
// 이제 emit('bridge', {...}) 하면 listen 핸들러가 불림
```
> ⚠️ 주의: `plugin:event|` 로 시작하는 invoke 를 **전부 가로챈다**(mocks.js:89). `shouldMockEvents` 를 켠 테스트에서는 이벤트 관련 invoke 가 우리 mock 콜백에 안 온다.
### 세 호스트 테스트할 때 (T012)
`mockIPC`**테스트 본문에서 런타임에** `window.__TAURI_INTERNALS__` 를 심는다. 기존 브릿지 테스트가 `window.chrome` 을 런타임에 심는 것과 똑같은 방식이다.
**transport 가 모듈 로드 시점에 호스트를 상수로 굳히면 이 mock 들이 전부 안 먹는다.** research R4 의 "한 번 판별하고 굳힌다"를 문자 그대로 구현하면 테스트 3개 + 신규 테스트가 다 깨진다. 판별 시점을 **최초 사용 시 1회**로 하거나, 굳히되 테스트용 재판별 훅을 같이 내야 한다 (`specs/004-tauri-shell/tasks.md` T015 가 이 결정을 먼저 하라고 박아둔 이유).
`clearMocks()` 를 쓸 거면 위 §3 의 `.invoke` 판별식이 **필수**다.
---
## 5. 안 쓰는 것
패키지에 이만큼 더 있지만 이 기능에서는 **안 쓴다** — 창·트레이·핫키·경로는 전부 Rust 담당이고, 프론트는 우리 커맨드만 부른다 (`contracts/transport-mapping.md`).
`window`, `webviewWindow`, `webview`, `menu`, `tray`, `path`, `app`, `dpi`, `image`
혹시 쓰게 되면 **`capabilities/default.json` 에 권한을 추가해야 한다.** 지금은 `core:default` 하나뿐이라 그것들은 **런타임에 조용히 거부**된다 (컴파일 에러 안 남). 자세한 건 `4_rust_tauri/docs-lib/tauri-v2.md` §9.
+53
View File
@@ -0,0 +1,53 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import jsxA11y from "eslint-plugin-jsx-a11y"
import tseslint from "typescript-eslint"
import prettierConfig from "eslint-config-prettier"
export default tseslint.config(
{ ignores: ["dist", "dist-tsbuild", "node_modules", "coverage"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"jsx-a11y": jsxA11y,
},
rules: {
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
{
// shadcn/ui generated components — third-party patterns, a11y rules relaxed
files: ["src/shared/ui/**/*.{ts,tsx}"],
rules: {
"jsx-a11y/heading-has-content": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/no-static-element-interactions": "off",
"react-refresh/only-export-components": "off",
},
},
{
// TanStack Table column defs — meta cell renderers can have click handlers
files: ["src/features/**/components/*Columns.tsx"],
rules: {
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-static-element-interactions": "off",
},
},
prettierConfig
)
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Frontend Template</title>
<script>
// FOUC 방지 — React 마운트 전 [data-theme] + .dark 미리 박음.
// 팔레트는 localStorage 'theme' 키, 다크모드는 'theme-store' (zustand persist).
;(function () {
try {
var pal = localStorage.getItem("theme") || "clean-blue"
// 이전 기본 베이지는 한 번만 교체. 이후 직접 고른 팔레트는 유지.
if (!localStorage.getItem("clean-blue-default-v1")) {
if (pal === "warm-tan") {
pal = "clean-blue"
localStorage.setItem("theme", pal)
}
localStorage.setItem("clean-blue-default-v1", "1")
}
document.documentElement.setAttribute("data-theme", pal)
var raw = localStorage.getItem("theme-store")
var mode = "light"
if (raw) {
try {
mode = (JSON.parse(raw).state || {}).theme || "light"
} catch (e) {}
}
var dark =
mode === "dark" ||
(mode === "system" &&
window.matchMedia &&
matchMedia("(prefers-color-scheme: dark)").matches)
if (dark) document.documentElement.classList.add("dark")
document.documentElement.style.colorScheme = dark ? "dark" : "light"
} catch (e) {}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+10694
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"name": "frontend-template",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@azure/msal-browser": "^3.30.0",
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^3.9.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.59.0",
"@tauri-apps/api": "^2.11.1",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"idb-keyval": "^6.3.0",
"lucide-react": "^0.453.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.27.0",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.4",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"axios-mock-adapter": "^2.1.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.11.0",
"jsdom": "^25.0.1",
"msw": "^2.14.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8",
"vitest": "^2.1.2"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from "@playwright/test"
const PORT = Number(process.env.PORT ?? 5173)
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+9101
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<text x="50%" y="50%" dy="0.35em" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
font-weight="700" font-size="36" fill="#38bdf8">F</text>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from "react"
import { useLocation, useNavigate, useRoutes } from "react-router-dom"
import { routes } from "./routes"
import { ErrorBoundary } from "./shared/components/ErrorBoundary"
import { initBridgeNavigate, setBridgeNavigate } from "@/lib/bridge/bridgeNavigate"
import { reportRoute } from "@/lib/bridge/webviewBridge"
import { DesktopWindowFrame } from "@/shared/components/DesktopWindowFrame"
export default function App() {
const element = useRoutes(routes)
const navigate = useNavigate()
const location = useLocation()
// C#가 navigate 푸시(예: Ctrl+Shift+7 → /snippet)를 보내면 이 콜백으로 라우팅.
useEffect(() => {
setBridgeNavigate(navigate)
initBridgeNavigate()
return () => setBridgeNavigate(null)
}, [navigate])
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용).
useEffect(() => {
reportRoute(location.pathname)
}, [location.pathname])
return (
<DesktopWindowFrame>
<ErrorBoundary>{element}</ErrorBoundary>
</DesktopWindowFrame>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest"
import { parseEnv } from "./env"
describe("parseEnv", () => {
it("VITE_API_BASE_URL 필수", () => {
expect(() => parseEnv({})).toThrow()
})
it("올바른 풀 URL 통과", () => {
const env = parseEnv({ VITE_API_BASE_URL: "http://localhost:8001/api" })
expect(env.apiBaseUrl).toBe("http://localhost:8001/api")
})
it("'/'로 시작하는 상대경로 통과 (vite proxy 전제)", () => {
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
expect(env.apiBaseUrl).toBe("/api/v1")
})
it("URL도 '/' 시작도 아니면 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "not-a-url" })).toThrow()
})
it("앞 '/' 없는 상대경로 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "api/v1" })).toThrow()
})
it("빈 문자열 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "" })).toThrow()
})
it("VITE_ENTRA_GRAPH_SCOPE 미지정 시 'User.Read' 기본값", () => {
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
expect(env.entraGraphScope).toBe("User.Read")
})
it("VITE_ENTRA_GRAPH_SCOPE 커스텀 값 통과", () => {
const env = parseEnv({
VITE_API_BASE_URL: "/api/v1",
VITE_ENTRA_GRAPH_SCOPE: "User.ReadBasic.All",
})
expect(env.entraGraphScope).toBe("User.ReadBasic.All")
})
})
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod"
// dev/prd 모두 single-origin 전제라 '/api/v1' 같은 상대경로가 기본.
// 다른 origin 직접 호출 시나리오만 풀 URL 허용.
const envSchema = z.object({
VITE_API_BASE_URL: z.union([
z.string().url(),
z.string().regex(/^\/[^\s]*$/, "must be absolute URL or path starting with '/'"),
]),
VITE_ENTRA_GRAPH_SCOPE: z.string().min(1).default("User.Read"),
})
export interface AppEnv {
apiBaseUrl: string
entraGraphScope: string
}
export function parseEnv(raw: Record<string, unknown>): AppEnv {
const parsed = envSchema.parse(raw)
return {
apiBaseUrl: parsed.VITE_API_BASE_URL,
entraGraphScope: parsed.VITE_ENTRA_GRAPH_SCOPE,
}
}
export const env: AppEnv = parseEnv(import.meta.env)
+10
View File
@@ -0,0 +1,10 @@
export const PATHS = {
HOME: "/snap",
LOGIN: "/login",
SNAP: "/snap",
SNAP_NEW: "/snap/new",
SNAP_SESSION: "/snap/s/:id",
SNIPPET: "/snippet",
} as const
export type Path = (typeof PATHS)[keyof typeof PATHS]
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import MockAdapter from "axios-mock-adapter"
import { authApi } from "./auth.api"
import { apiClient } from "@/lib/api/client"
const fakeToken = {
token: "a",
tokenExpirationTime: 0,
refreshToken: "r",
refreshTokenExpirationTime: 0,
tokenType: "bearer",
user: { id: "u", email: "x@x.com", userName: null, role: "USER" as const },
}
function envelope<T>(data: T) {
return {
success: true,
statusCode: 200,
code: null,
message: null,
data,
counts: null,
errors: [] as string[],
timestamp: "2026-01-01T00:00:00Z",
meta: null,
}
}
let mock: MockAdapter
beforeEach(() => {
mock = new MockAdapter(apiClient)
})
afterEach(() => {
mock.restore()
})
describe("authApi", () => {
it("login POST /auth/login → user 반환 (token은 쿠키로 처리)", async () => {
let body: unknown
mock.onPost("/auth/login").reply((config) => {
body = JSON.parse(config.data as string)
return [200, envelope(fakeToken)]
})
const user = await authApi.login({ email: "x@x.com", password: "abcd" })
expect(user).toEqual(fakeToken.user)
expect(body).toEqual({ email: "x@x.com", password: "abcd" })
})
it("refresh POST /auth/refresh — body 없음", async () => {
let body: unknown
mock.onPost("/auth/refresh").reply((config) => {
body = config.data
return [200, envelope(fakeToken)]
})
await authApi.refresh()
expect(body).toBeUndefined()
})
it("logout POST /auth/logout — __skipAuth로 401에서도 인터셉터 우회", async () => {
let called = false
mock.onPost("/auth/logout").reply(() => {
called = true
return [200, envelope(null)]
})
await authApi.logout()
expect(called).toBe(true)
})
it("getMe GET /users/me", async () => {
mock.onGet("/users/me").reply(200, envelope({ ...fakeToken.user, isActive: true }))
const me = await authApi.getMe()
expect(me.email).toBe("x@x.com")
})
})
describe("entraLogin", () => {
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER" as const,
employeeId: "EMP1",
department: "IT",
authProvider: "entra" as const,
}
mock.onPost("/auth/entra/login").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: {
token: "t",
tokenExpirationTime: 1,
refreshToken: "r",
refreshTokenExpirationTime: 2,
tokenType: "bearer",
user,
},
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.entraLogin({ idToken: "ID", graphAccessToken: "G" })
expect(result).toEqual(user)
})
})
describe("getEntraConfig", () => {
it("200 응답 → config 반환", async () => {
const cfg = { clientId: "c", authority: "https://...", tenantId: "t" }
mock.onGet("/auth/entra/config").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: cfg,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toEqual(cfg)
})
it("501 응답 → null 반환 (throw 안 함)", async () => {
mock.onGet("/auth/entra/config").reply(501, {
success: false,
statusCode: 501,
code: "ENTRA_NOT_CONFIGURED",
message: "Entra ID SSO 가 설정되지 않았습니다.",
data: null,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toBeNull()
})
it("500 응답 → throw", async () => {
mock.onGet("/auth/entra/config").reply(500)
await expect(authApi.getEntraConfig()).rejects.toThrow()
})
})
@@ -0,0 +1,62 @@
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
import { ApiError } from "@/lib/api/errors"
import type {
LoginRequest,
TokenResponse,
UserResponse,
UserPayload,
EntraLoginRequest,
EntraConfigResponse,
} from "@/types/api"
const SKIP_AUTH: CallerConfig = { __skipAuth: true }
/**
* 인증 API. 모두 axios apiClient 사용 (쿠키 자동 전송).
*
* - login: 응답 body의 user만 사용 (토큰은 Set-Cookie로 처리됨)
* - refresh: 쿠키만으로 진행 (body 없음). 호출자는 결과 신경 X — interceptor에서 자동 처리
* - logout: 인증 불필요. 쿠키 삭제 + 클라 store도 함께 비우기
* - getMe: 마운트 시 서버 진실값으로 user 복원
*/
export const authApi = {
/**
* __skipAuth: 로그인 전이므로 401 응답 시 refresh 인터셉터가 개입하면 안 됨.
* 잘못된 credentials → 백엔드 401 → 인터셉터 refresh 시도 → sessionExpiry 모달 오발사 방지.
*/
login: async (req: LoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
return tokens.user
},
/**
* refresh — 쿠키 기반. 인터셉터의 자동 refresh와 별개로 명시적 호출용(sliding refresh 등).
* 실패 시 ApiError throw. 성공 시 새 쿠키가 Set-Cookie로 갱신됨.
*/
refresh: async (): Promise<void> => {
await apiPost<TokenResponse>("/auth/refresh", undefined)
},
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
/**
* __skipAuth: ENTRA_TOKEN_INVALID(401) 응답 시 refresh 인터셉터가 개입하면 안 됨.
* Microsoft 토큰 검증 실패 → 백엔드 401 → 인터셉터 refresh → sessionExpiry 모달 오발사 방지.
*/
entraLogin: async (req: EntraLoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/entra/login", req, SKIP_AUTH)
return tokens.user
},
getEntraConfig: async (): Promise<EntraConfigResponse | null> => {
try {
return await apiGet<EntraConfigResponse>("/auth/entra/config", SKIP_AUTH)
} catch (e) {
if (e instanceof ApiError && e.status === 501) return null
throw e
}
},
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import EntraLoginButton from "./EntraLoginButton"
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
const navigateMock = vi.fn()
const invalidateQueries = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
let mutationState = { isPending: false }
vi.mock("../hooks/useEntraLogin", () => ({
useEntraLogin: () => ({
mutate,
get isPending() {
return mutationState.isPending
},
}),
}))
vi.mock("@tanstack/react-query", async (orig) => {
const m: any = await orig()
return { ...m, useQueryClient: () => ({ invalidateQueries }) }
})
function renderBtn(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<EntraLoginButton />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => {
vi.clearAllMocks()
mutationState = { isPending: false }
})
describe("EntraLoginButton", () => {
it("버튼 렌더 + 텍스트", () => {
renderBtn()
expect(screen.getByRole("button", { name: /Microsoft로 로그인/ })).toBeInTheDocument()
})
it("isPending 동안 disabled + '로그인 중...'", () => {
mutationState.isPending = true
renderBtn()
const btn = screen.getByRole("button")
expect(btn).toBeDisabled()
expect(btn).toHaveTextContent("로그인 중...")
})
it("클릭 → mutate 호출 (from 정상 → onSuccess에서 navigate)", async () => {
renderBtn("?from=/snap/new")
await userEvent.click(screen.getByRole("button"))
expect(mutate).toHaveBeenCalledOnce()
// onSuccess 콜백 실행 시뮬레이션
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 보호)", async () => {
renderBtn("?from=//evil.com")
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
it("onError: user_cancelled → toast 안 띄움", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError({ errorCode: "user_cancelled" })
expect(toast.error).not.toHaveBeenCalled()
})
it("onError: popup_window_error → 팝업 차단 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new AuthError("popup_window_error"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/팝업/))
})
it("onError: ApiError 401 → 인증 실패 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(401, "Microsoft 인증 실패", [], "AUTH_FAIL")
opts.onError(err)
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Microsoft 인증에 실패/))
expect(invalidateQueries).not.toHaveBeenCalled()
})
it("onError: ApiError 501 → invalidateQueries + 비활성 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(501, "Entra 비활성", [], "ENTRA_NOT_CONFIGURED")
opts.onError(err)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["entra-config"] })
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/비활성화/))
})
it("onError: 모르는 에러 → '로그인 오류: ...' 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new Error("???"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/로그인 오류.*\?{3}/))
})
})
@@ -0,0 +1,80 @@
import { useNavigate, useSearchParams } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { Button } from "@/shared/ui/button"
import { ApiError } from "@/lib/api/errors"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { useEntraLogin } from "../hooks/useEntraLogin"
export default function EntraLoginButton() {
const login = useEntraLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const queryClient = useQueryClient()
const onClick = () => {
const from = safeRedirectPath(params.get("from"))
login.mutate(undefined, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err: unknown) => {
console.error("[EntraLogin] error:", err)
// ── MSAL 팝업/브라우저 에러 ──────────────────────────
if (err instanceof AuthError) {
const code = err.errorCode
if (code === "user_cancelled") return
if (code === "popup_window_error") {
toast.error("팝업이 차단되었습니다. 브라우저 설정을 확인하십시오.")
return
}
if (code === "interaction_in_progress") {
toast.error("로그인이 진행 중입니다. 잠시 후 다시 시도하십시오.")
return
}
if (code === "monitor_window_timeout" || code === "empty_window_error") {
toast.error("팝업 창이 닫혔습니다. 다시 시도하십시오.")
return
}
toast.error(`Microsoft 인증 오류: ${err.message}`)
return
}
// ── 구형 방식(errorCode 프로퍼티만 있는 객체) 호환 ──
const legacyCode = (err as { errorCode?: string } | null)?.errorCode
if (legacyCode === "user_cancelled") return
// ── 백엔드 API 에러 ──────────────────────────────────
if (err instanceof ApiError) {
if (err.status === 501) {
queryClient.invalidateQueries({ queryKey: ["entra-config"] })
toast.error("Microsoft 로그인이 서버에서 비활성화되어 있습니다.")
return
}
if (err.status === 401) {
toast.error("Microsoft 인증에 실패했습니다. 다시 시도하십시오.")
return
}
toast.error(err.message || "Microsoft 로그인 실패")
return
}
// ── 네트워크 또는 예상치 못한 에러 ──────────────────
const msg = err instanceof Error ? err.message : String(err)
toast.error(`로그인 오류: ${msg}`)
},
})
}
return (
<Button
type="button"
variant="outline"
onClick={onClick}
disabled={login.isPending}
className="w-full"
>
{login.isPending ? "로그인 중..." : "Microsoft로 로그인"}
</Button>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import EntraLoginSection from "./EntraLoginSection"
let mockState = { enabled: false, isLoading: false }
vi.mock("../hooks/useEntraEnabled", () => ({
useEntraEnabled: () => mockState,
}))
vi.mock("./EntraLoginButton", () => ({
default: () => <button data-testid="entra-btn">stub</button>,
}))
beforeEach(() => {
mockState = { enabled: false, isLoading: false }
})
describe("EntraLoginSection", () => {
it("enabled=false → null 렌더", () => {
mockState = { enabled: false, isLoading: false }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("isLoading=true → null 렌더", () => {
mockState = { enabled: false, isLoading: true }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("enabled=true → 버튼 + divider '또는' 같이 노출", () => {
mockState = { enabled: true, isLoading: false }
render(<EntraLoginSection />)
expect(screen.getByTestId("entra-btn")).toBeInTheDocument()
expect(screen.getByText("또는")).toBeInTheDocument()
})
})
@@ -0,0 +1,21 @@
import EntraLoginButton from "./EntraLoginButton"
import { useEntraEnabled } from "../hooks/useEntraEnabled"
export default function EntraLoginSection() {
const { enabled, isLoading } = useEntraEnabled()
if (isLoading || !enabled) return null
return (
<>
<EntraLoginButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2"></span>
</div>
</div>
</>
)
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { PATHS } from "@/config/routes"
import LoginForm from "./LoginForm"
vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn() } }))
const navigateMock = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
vi.mock("../hooks/useLogin", () => ({
useLogin: () => ({ mutate, isPending: false }),
}))
function renderForm(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<LoginForm />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => vi.clearAllMocks())
describe("LoginForm 회귀", () => {
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
expect(mutate).toHaveBeenCalledOnce()
expect(mutate.mock.calls[0][0]).toMatchObject({ email: "a@b.com", password: "secret" })
})
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
const { container } = renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
await userEvent.type(screen.getByLabelText("비번"), "secret")
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
const form = container.querySelector("form")!
fireEvent.submit(form)
// "올바른 이메일을 입력하십시오" 에러 메시지 — Label "이메일"과 구분되도록 더 구체적인 regex
// zod resolver 가 async 라 findByText (대기) 사용
expect(await screen.findByText(/올바른 이메일/)).toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
})
it("?expired=1 → 세션 만료 토스트 1회", async () => {
renderForm("?expired=1")
expect(toast.info).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith(expect.stringMatching(/세션/))
})
it("from=/snap/new + 성공 → navigate('/snap/new')", async () => {
renderForm("?from=/snap/new")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 가드)", async () => {
renderForm("?from=//evil.com")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
})
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useNavigate, useSearchParams } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { ApiError } from "@/lib/api/errors"
import type { LoginRequest } from "@/types/api"
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
const login = useLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const expiredToastedRef = useRef(false)
// ?expired=1 → 토스트 1회 (StrictMode 이중 렌더 가드)
useEffect(() => {
if (params.get("expired") === "1" && !expiredToastedRef.current) {
expiredToastedRef.current = true
toast.info("세션이 만료되어 로그아웃되었습니다. 다시 로그인하십시오.")
}
}, [params])
const onSubmit = (data: LoginInput) => {
const from = safeRedirectPath(params.get("from"))
login.mutate(data as LoginRequest, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-sm space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && <p className="text-destructive text-sm">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? "로그인 중..." : "로그인"}
</Button>
</form>
)
}
@@ -0,0 +1,121 @@
import { useNavigate } from "react-router-dom"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import type { LoginRequest } from "@/types/api"
import { useSessionExpiryStore } from "../store/sessionExpiryStore"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { useAuthStore } from "../store/authStore"
/**
* 세션 만료(401 + refresh 실패) 시 자동으로 뜨는 모달.
*
* - 강제 닫기 불가(인증 안 된 상태로 화면 노출되면 안 되니까)
* - 인라인 재로그인 폼 → 성공하면 큐의 retry 발사 → 모달 닫힘
* - "로그아웃하고 로그인 페이지로" 옵션 — 큐 폐기 후 `/login?expired=1`로 이동
*
* 보호된 팔레트 화면을 감싸는 `PaletteShell`에 1회 마운트.
*/
export function SessionExpiryDialog() {
const open = useSessionExpiryStore((s) => s.open)
const cancel = useSessionExpiryStore((s) => s.cancel)
const closeAndFlush = useSessionExpiryStore((s) => s.closeAndFlush)
const clearUser = useAuthStore((s) => s.clearUser)
const login = useLogin()
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: user?.email ?? "", password: "" },
})
const onSubmit = (data: LoginInput) => {
login.mutate(data as LoginRequest, {
onSuccess: async () => {
reset({ email: user?.email ?? "", password: "" })
await closeAndFlush()
},
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
const goToLogin = () => {
cancel()
clearUser()
navigate(`${PATHS.LOGIN}?expired=1`, { replace: true })
}
return (
<Dialog open={open}>
<DialogContent
// 외부 클릭·ESC로 닫기 차단 — 강제 재로그인 또는 명시적 로그아웃 두 길만
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="sm:max-w-sm"
>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="session-email"></Label>
<Input
id="session-email"
type="email"
autoComplete="email"
readOnly={!!user?.email}
{...register("email")}
/>
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="session-password"></Label>
<Input
id="session-password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && (
<p className="text-destructive text-sm">{errors.password.message}</p>
)}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={goToLogin}>
</Button>
<Button type="submit" disabled={login.isPending}>
{login.isPending ? "로그인 중..." : "다시 로그인"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import { useEntraEnabled } from "./useEntraEnabled"
vi.mock("../api/auth.api", () => ({
authApi: { getEntraConfig: vi.fn() },
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraEnabled", () => {
it("config null → enabled=false, isLoading 결국 false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue(null)
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(false)
})
it("config 있음 → enabled=true", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue({
clientId: "c",
authority: "https://a",
tenantId: "t",
})
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(true)
})
it("로딩 중 → isLoading=true, enabled=false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
expect(result.current.isLoading).toBe(true)
expect(result.current.enabled).toBe(false)
})
})
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
/**
* Entra SSO 활성 여부 — `/auth/entra/config` 응답으로 판단.
* - config 객체 있음 → enabled=true
* - config null (501) → enabled=false
* staleTime Infinity: 세션 동안 한 번만 받아옴.
*/
export function useEntraEnabled() {
const q = useQuery({
queryKey: ["entra-config"],
queryFn: () => authApi.getEntraConfig(),
staleTime: Infinity,
retry: false,
})
return {
enabled: q.data !== null && q.data !== undefined,
isLoading: q.isLoading,
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
vi.mock("@/lib/auth/msal", () => ({
loginWithMicrosoft: vi.fn(),
}))
vi.mock("../api/auth.api", () => ({
authApi: { entraLogin: vi.fn() },
}))
vi.mock("../store/authStore", () => ({
useAuthStore: vi.fn(),
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraLogin", () => {
it("정상 흐름 → MSAL → entraLogin → setUser 순서 호출", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: "G",
})
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER",
employeeId: "E",
department: "D",
authProvider: "entra",
}
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue(user)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(loginWithMicrosoft).toHaveBeenCalledOnce()
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: "G",
})
expect(setUser).toHaveBeenCalledWith(user)
})
it("graphAccessToken null → payload에서 graphAccessToken 누락(undefined)", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: null,
})
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue({ id: "u", email: "x" })
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: undefined,
})
})
it("MSAL throw → mutation isError + setUser 호출 X", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
const err: any = new Error("popup_window_error")
err.errorCode = "popup_window_error"
;(loginWithMicrosoft as any).mockRejectedValue(err)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync()
} catch {
// mutation 실패 의도된 케이스
}
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(setUser).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,18 @@
import { useMutation } from "@tanstack/react-query"
import { loginWithMicrosoft } from "@/lib/auth/msal"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
export function useEntraLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: async () => {
const { idToken, graphAccessToken } = await loginWithMicrosoft()
return authApi.entraLogin({
idToken,
graphAccessToken: graphAccessToken ?? undefined,
})
},
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,12 @@
import { useMutation } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
import type { LoginRequest } from "@/types/api"
export function useLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: (input: LoginRequest) => authApi.login(input),
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* logout( ) store react-query .
*
* (· ).
*/
export function useLogout() {
const clearUser = useAuthStore((s) => s.clearUser)
const qc = useQueryClient()
return useMutation({
mutationFn: () => authApi.logout(),
onSettled: () => {
clearUser()
qc.clear()
},
})
}
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* (`/users/me`) user를 .
* persist된 user(localStorage) UX .
*/
export function useMe(enabled = true) {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ["auth", "me"],
queryFn: () => authApi.getMe(),
enabled: enabled && user !== null,
staleTime: 5 * 60_000,
retry: false,
})
}
@@ -0,0 +1,57 @@
import { useEffect } from "react"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
const COOKIE_NAME = "accessTokenExp"
const REFRESH_BEFORE_MS = 60_000
function readExpCookie(): number | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
if (!match) return null
const n = Number(match[1])
return Number.isFinite(n) ? n : null
}
/**
* accessToken 60 refresh.
*
* - `accessTokenExp` non-httpOnly (ms epoch)
* - .
* - refresh 401 + sessionExpiryStore
*
* `PaletteShell` 1 .
*/
export function useSlidingRefresh() {
const isAuthed = useAuthStore((s) => s.isAuthenticated())
useEffect(() => {
if (!isAuthed) return
let timerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const schedule = () => {
const exp = readExpCookie()
if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
timerId = setTimeout(async () => {
if (cancelled) return
try {
await authApi.refresh()
} catch {
// 인터셉터가 401 → sessionExpiryStore로 이미 처리. 여기선 무시.
return
}
if (!cancelled) schedule()
}, delay)
}
schedule()
return () => {
cancelled = true
if (timerId !== null) clearTimeout(timerId)
}
}, [isAuthed])
}
+2
View File
@@ -0,0 +1,2 @@
export { default as LoginPage } from "./pages/LoginPage"
export { useAuthStore } from "./store/authStore"
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import LoginPage from "./LoginPage"
vi.mock("../components/EntraLoginSection", () => ({
default: () => <div data-testid="entra-section">stub</div>,
}))
vi.mock("../components/LoginForm", () => ({
default: () => <form data-testid="login-form">stub</form>,
}))
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<LoginPage />
</MemoryRouter>
</QueryClientProvider>
)
}
describe("LoginPage", () => {
it("EntraLoginSection + LoginForm 둘 다 마운트", () => {
renderPage()
expect(screen.getByTestId("entra-section")).toBeInTheDocument()
expect(screen.getByTestId("login-form")).toBeInTheDocument()
})
it("EntraLoginSection이 LoginForm보다 먼저 옴 (DOM 순서)", () => {
renderPage()
const section = screen.getByTestId("entra-section")
const form = screen.getByTestId("login-form")
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("h1 '로그인' 표시", () => {
renderPage()
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
})
it("로그인 입력 영역을 화면 가운데 정렬", () => {
const { container } = renderPage()
expect(container.firstElementChild).toHaveClass(
"flex",
"flex-1",
"items-center",
"justify-center"
)
})
})
@@ -0,0 +1,14 @@
import LoginForm from "../components/LoginForm"
import EntraLoginSection from "../components/EntraLoginSection"
export default function LoginPage() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-semibold"></h1>
<EntraLoginSection />
<LoginForm />
</div>
</div>
)
}
@@ -0,0 +1,7 @@
import { z } from "zod"
export const loginSchema = z.object({
email: z.string().email("올바른 이메일을 입력하십시오"),
password: z.string().min(4, "최소 4자"),
})
export type LoginInput = z.infer<typeof loginSchema>
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useAuthStore } from "./authStore"
const sampleUser = {
id: "u1",
email: "x@x.com",
userName: null,
role: "USER" as const,
employeeId: null,
department: null,
authProvider: "local" as const,
}
describe("authStore", () => {
beforeEach(() => {
useAuthStore.setState({ user: null })
localStorage.clear()
})
it("초기 상태: user null, isAuthenticated false", () => {
const s = useAuthStore.getState()
expect(s.user).toBeNull()
expect(s.isAuthenticated()).toBe(false)
})
it("setUser: user 저장 + isAuthenticated true", () => {
useAuthStore.getState().setUser(sampleUser)
expect(useAuthStore.getState().user).toEqual(sampleUser)
expect(useAuthStore.getState().isAuthenticated()).toBe(true)
})
it("clearUser: user null", () => {
useAuthStore.setState({ user: sampleUser })
useAuthStore.getState().clearUser()
expect(useAuthStore.getState().user).toBeNull()
expect(useAuthStore.getState().isAuthenticated()).toBe(false)
})
// anti-pattern 가드: token/accessToken/refreshToken 필드는 절대 추가하지 않음 (httpOnly 쿠키만 사용)
it("[anti-pattern] state에 token 관련 필드가 없음", () => {
const state = useAuthStore.getState() as unknown as Record<string, unknown>
expect(state).not.toHaveProperty("token")
expect(state).not.toHaveProperty("accessToken")
expect(state).not.toHaveProperty("refreshToken")
})
it("[anti-pattern] setUser 후 persist에 token이 포함되지 않음", () => {
useAuthStore.getState().setUser(sampleUser)
const raw = localStorage.getItem("auth-store")
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw!) as { state: Record<string, unknown> }
expect(parsed.state).not.toHaveProperty("token")
expect(parsed.state).not.toHaveProperty("accessToken")
expect(parsed.state).not.toHaveProperty("refreshToken")
expect(parsed.state.user).toEqual(sampleUser)
})
})
@@ -0,0 +1,34 @@
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import type { UserPayload } from "@/types/api"
/**
* . ** ** (httpOnly ).
*
* `token`/`accessToken`/`refreshToken` anti-pattern test로 .
*
* `user` localStorage에 (UX용. ).
* `useMe()` .
*/
interface AuthState {
user: UserPayload | null
isAuthenticated: () => boolean
setUser: (user: UserPayload | null) => void
clearUser: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: () => get().user !== null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}),
{
name: "auth-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ user: state.user }),
}
)
)
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { useSessionExpiryStore } from "./sessionExpiryStore"
describe("sessionExpiryStore", () => {
beforeEach(() => {
useSessionExpiryStore.setState({ open: false, queue: [] })
})
it("초기 상태: 닫힘 + 큐 비어있음", () => {
const s = useSessionExpiryStore.getState()
expect(s.open).toBe(false)
expect(s.queue).toEqual([])
})
it("openDialog: open=true", () => {
useSessionExpiryStore.getState().openDialog()
expect(useSessionExpiryStore.getState().open).toBe(true)
})
it("pushFailure: retry를 큐에 push + open=true + 호출자 promise 보관", () => {
const retry = vi.fn().mockResolvedValue("ok")
const promise = useSessionExpiryStore.getState().pushFailure(retry)
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(true)
expect(state.queue.length).toBe(1)
expect(retry).not.toHaveBeenCalled() // flush 전엔 호출 안 됨
// dangling promise 정리
state.cancel()
promise.catch(() => {})
})
it("closeAndFlush: 큐에 쌓인 retry 모두 실행 + open=false + queue=[]", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockResolvedValue("b")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
await useSessionExpiryStore.getState().closeAndFlush()
expect(retry1).toHaveBeenCalledTimes(1)
expect(retry2).toHaveBeenCalledTimes(1)
await expect(p1).resolves.toBe("a")
await expect(p2).resolves.toBe("b")
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
})
it("flush 중 retry 한 건이 실패해도 나머지 진행", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockRejectedValue(new Error("boom"))
const retry3 = vi.fn().mockResolvedValue("c")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
const p3 = useSessionExpiryStore.getState().pushFailure(retry3)
await useSessionExpiryStore.getState().closeAndFlush()
await expect(p1).resolves.toBe("a")
await expect(p2).rejects.toThrow("boom")
await expect(p3).resolves.toBe("c")
})
it("cancel: open=false + 큐 폐기 (호출자 promise는 dangling)", () => {
const retry = vi.fn()
useSessionExpiryStore.getState().pushFailure(retry)
useSessionExpiryStore.getState().cancel()
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
expect(retry).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { create } from "zustand"
/**
* (401 + refresh ) .
* retry.
*
* store는 user . `authStore` .
*/
export type RetryFn = () => Promise<unknown>
interface SessionExpiryState {
open: boolean
/** refresh 실패로 reject 직전에 대기 중인 요청들의 retry 함수 큐. */
queue: RetryFn[]
/** 모달 열기. 동일 사이클에서 여러 401이 와도 1회만 열림. */
openDialog: () => void
/** 재로그인 성공 시: 큐 전부 retry → resolve/reject 각자에게 위임 → 모달 닫음. */
closeAndFlush: () => Promise<void>
/** 사용자가 명시적으로 모달 닫기(취소·로그아웃 등). 큐도 폐기. */
cancel: () => void
/** 401 + refresh 실패 직전에 retry 함수 push. 호출자는 반환된 promise로 결과 받음. */
pushFailure: (retry: RetryFn) => Promise<unknown>
}
export const useSessionExpiryStore = create<SessionExpiryState>((set, get) => ({
open: false,
queue: [],
openDialog: () => set({ open: true }),
cancel: () => set({ open: false, queue: [] }),
closeAndFlush: async () => {
const { queue } = get()
set({ open: false, queue: [] })
// 각 retry는 독립 실행 — 한 건 실패해도 나머지 진행
await Promise.allSettled(queue.map((fn) => fn()))
},
pushFailure: (retry) => {
return new Promise((resolve, reject) => {
const wrapped: RetryFn = async () => {
try {
const result = await retry()
resolve(result)
return result
} catch (err) {
reject(err)
throw err
}
}
set((s) => ({ queue: [...s.queue, wrapped], open: true }))
})
},
}))
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { safeRedirectPath } from "./safeRedirectPath"
import { PATHS } from "@/config/routes"
describe("safeRedirectPath", () => {
it("null → SNAP", () => {
expect(safeRedirectPath(null)).toBe(PATHS.SNAP)
})
it("빈 문자열 → SNAP", () => {
expect(safeRedirectPath("")).toBe(PATHS.SNAP)
})
it("'/'로 시작 안 함 → SNAP", () => {
expect(safeRedirectPath("snap/new")).toBe(PATHS.SNAP)
})
it("'//' protocol-relative URL → SNAP", () => {
expect(safeRedirectPath("//evil.com/path")).toBe(PATHS.SNAP)
})
it("정상 절대 경로 → 그대로 반환", () => {
expect(safeRedirectPath(PATHS.SNAP_NEW)).toBe(PATHS.SNAP_NEW)
})
it("쿼리스트링 포함된 경로 → 그대로", () => {
expect(safeRedirectPath("/users?page=2")).toBe("/users?page=2")
})
})
@@ -0,0 +1,12 @@
import { PATHS } from "@/config/routes"
/**
* redirect할 path .
* - `/`
* - `//` protocol-relative URL이라
*/
export function safeRedirectPath(raw: string | null): string {
if (!raw) return PATHS.SNAP
if (!raw.startsWith("/") || raw.startsWith("//")) return PATHS.SNAP
return raw
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 를 페이지네이션으로 호출", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions", {
params: { page: 1, limit: 3 },
})
expect(result.current.data!.items[0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
@@ -0,0 +1,56 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapMessage, SnapSession, SnapSessionDetail } from "../contract/types"
const PAGE_SIZE = 3 // 첫 화면 "최근 진행 대화"는 3개만 peek — 나머지는 검색으로
const SEARCH_PAGE_SIZE = 20
// 세션 목록 — 서버 페이지네이션. page 단위로 목록 교체(더보기 append 아님).
export function useSessionList(page: number) {
return useQuery({
queryKey: ["snap", "sessions", page],
queryFn: () => apiList<SnapSession>("/chat/sessions", { params: { page, limit: PAGE_SIZE } }),
placeholderData: keepPreviousData,
// 전역 staleTime 30s 끔 — 새 세션 만들고 30초 내 목록 복귀 시 캐시가 그대로 나와
// "방금 만든 세션이 목록에 없음"이 되던 원인. 목록 진입마다 refetch.
staleTime: 0,
})
}
// 메시지 본문 검색 — 서버 /chat/sessions/search (ILIKE, 소유 세션 전체 대상). 매칭된 메시지 반환.
export function useSearchMessages(query: string, page: number) {
return useQuery({
queryKey: ["snap", "search", query, page],
enabled: query.trim().length > 0,
staleTime: 0, // 같은 검색어 재검색 시에도 최신 메시지 반영
queryFn: () =>
apiList<SnapMessage>("/chat/sessions/search", {
params: { query, page, limit: SEARCH_PAGE_SIZE },
}),
placeholderData: keepPreviousData,
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
// 전역 staleTime 30s 를 끔 — 대화 내용은 스트리밍으로 계속 바뀌어서 30초 캐시가
// "재진입하면 빈/옛 대화" 버그의 뿌리였음. 재진입(마운트)마다 무조건 refetch.
staleTime: 0,
// 생성 중이면(스트림을 이 탭에서 잃었어도) 백엔드가 끝내는 순간 답변이 DB 에 뜨므로
// 그때까지 폴링 → 완료되면 isGenerating=false 로 폴링 멈춤. 재진입 시 답변 유실 자가복구.
refetchInterval: (query) => (query.state.data?.isGenerating ? 1500 : false),
})
}
export function useCreateSession() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
// 생성 즉시 목록 캐시 무효화 — 목록으로 돌아가면 새 세션이 바로 보이게.
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] }),
})
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream, cancelStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
import * as client from "@/lib/api/client"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("이미지 계약을 요청 body에 그대로 전달한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
await snapStream(
{ sessionId: "s1", content: "", images },
{ onToken: vi.fn(), onDone: vi.fn() }
)
expect(streamLLM.mock.calls[0][0].body).toEqual({ sessionId: "s1", content: "", images })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: {
handlers: {
onToken: (d: string) => void
onTitle?: (t: string) => void
onDone: (p: object) => void
}
}) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
}
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
describe("cancelStream", () => {
beforeEach(() => vi.clearAllMocks())
it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => {
vi.mocked(client.apiPost).mockResolvedValue(null)
await cancelStream("s1")
expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {})
})
it("실패해도 throw 하지 않는다(best-effort)", async () => {
vi.mocked(client.apiPost).mockRejectedValue(new Error("404"))
await expect(cancelStream("s1")).resolves.toBeUndefined()
})
})
@@ -0,0 +1,41 @@
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
import { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal }
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onUsage: handlers.onUsage,
onError: handlers.onError,
},
})
}
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
export async function cancelStream(sessionId: string): Promise<void> {
try {
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
} catch {
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
}
}
@@ -0,0 +1,54 @@
import { ChevronLeft } from "lucide-react"
import { useShallow } from "zustand/react/shallow"
import { Kbd } from "@/shared/components/Kbd"
import type { SnapSession } from "../contract/types"
import { useSnapChatStore } from "../store/snapChatStore"
import { fmtTokens } from "../lib/format"
interface Props {
session: SnapSession
onBack: () => void
}
export function ChatHeader({ session, onBack }: Props) {
const title = session.titleLlm ?? session.title ?? "새 대화 세션"
const { used, limit } = useSnapChatStore(
useShallow((s) => ({ used: s.sessionUsed, limit: s.sessionLimit }))
)
const ratio = limit > 0 ? Math.min(1, used / limit) : 0
// 게이지 색 — 90%↑ 빨강, 70%↑ 주황, 그 외 기본.
const barColor = ratio >= 0.9 ? "bg-red-500" : ratio >= 0.7 ? "bg-amber-500" : "bg-primary"
return (
<div className="border-border bg-card flex flex-none items-center gap-2 border-b px-3 py-2.5">
<button
type="button"
onClick={onBack}
title="목록으로 (Esc)"
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
>
<ChevronLeft className="size-3" />
<Kbd>Esc</Kbd>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
<div className="text-muted-foreground font-mono text-[9.5px] tracking-wide uppercase">
{(session.tag ?? "SESSION").toUpperCase()} ·
</div>
</div>
{/* 세션 토큰 게이지 — 현재 점유 / 한도 */}
<div
className="flex flex-none flex-col items-end gap-1"
title={`${used.toLocaleString()} / ${limit.toLocaleString()} tokens`}
>
<span className="text-muted-foreground font-mono text-[10px] tabular-nums">
{fmtTokens(used)}
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
</span>
<div className="bg-border h-1 w-24 overflow-hidden rounded-full">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${ratio * 100}%` }} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react"
import { get, set } from "idb-keyval"
import { Clipboard, Type } from "lucide-react"
import { toast } from "sonner"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"
// IndexedDB 에 저장하는 형태 — 이미지는 Blob 그대로(base64 불필요, 용량 절약).
type StoredItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob }
// 렌더용 — 이미지는 <img> 에 물릴 object URL 을 얹은 형태.
type ClipItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob; url: string }
const STORE_KEY = "snap-clip-history"
const CAP = 30 // 최대 저장 개수. 화면은 스크롤로 ~10개 보이고 나머지는 굴려서.
function newId(): string {
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : String(Date.now())
}
// 저장형 → 렌더형(이미지에 object URL 부여). 반대는 url 만 떼면 됨.
function toClip(stored: StoredItem[]): ClipItem[] {
return stored.map((i) => (i.kind === "image" ? { ...i, url: URL.createObjectURL(i.blob) } : i))
}
function toStored(items: ClipItem[]): StoredItem[] {
return items.map((i) => (i.kind === "image" ? { id: i.id, kind: "image", blob: i.blob } : i))
}
export function ClipboardHistory() {
const [items, setItems] = useState<ClipItem[]>([])
// 마운트 시 IndexedDB 에서 로드. hydrate 전엔 persist 를 막아 빈 배열로 덮어쓰는 레이스 방지.
const hydrated = useRef(false)
useEffect(() => {
let cancelled = false
get<StoredItem[]>(STORE_KEY)
.then((stored) => {
if (!cancelled && stored?.length) setItems(toClip(stored))
})
.finally(() => {
hydrated.current = true
})
return () => {
cancelled = true
}
}, [])
// 이력 변경 시 IndexedDB 에 통째로 저장(텍스트+이미지 blob 둘 다 로컬 영속).
useEffect(() => {
if (!hydrated.current) return
set(STORE_KEY, toStored(items)).catch(() => {
// storage 실패 — degrade
})
}, [items])
// 새 clip 을 맨 앞에. 직전과 같은 텍스트면 스킵(중복 방지). CAP 초과분은 잘라내며 object URL 정리.
const add = (item: ClipItem) =>
setItems((prev) => {
if (item.kind === "text" && prev[0]?.kind === "text" && prev[0].value === item.value)
return prev
const next = [item, ...prev]
for (const dropped of next.slice(CAP)) {
if (dropped.kind === "image") URL.revokeObjectURL(dropped.url)
}
return next.slice(0, CAP)
})
// 클립보드 소스 2입구(paste 폴백 + 네이티브 postMessage).
// 계약: { type: "clipboard", payload: {kind:"text", value} | {kind:"image", dataUrl} }
const addRef = useRef(add)
addRef.current = add
useEffect(() => {
const imageItem = (blob: Blob): ClipItem => ({
id: newId(),
kind: "image",
blob,
url: URL.createObjectURL(blob),
})
const onPaste = (e: ClipboardEvent) => {
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
if (dt.items[i].type.startsWith("image/")) {
const blob = dt.items[i].getAsFile()
if (!blob) return
addRef.current(imageItem(blob))
return
}
}
const text = dt.getData("text/plain")
if (text) addRef.current({ id: newId(), kind: "text", value: text })
}
const onMessage = (e: MessageEvent) => {
const d = e.data
if (!d || d.type !== "clipboard") return
const p = d.payload
if (p?.kind === "image" && typeof p.dataUrl === "string") {
// data URL → Blob 로 변환해 저장(paste 와 동일 취급).
fetch(p.dataUrl)
.then((r) => r.blob())
.then((blob) => addRef.current(imageItem(blob)))
.catch(() => {})
} else if (p?.kind === "text" && typeof p.value === "string") {
addRef.current({ id: newId(), kind: "text", value: p.value })
}
}
document.addEventListener("paste", onPaste)
window.addEventListener("message", onMessage)
return () => {
document.removeEventListener("paste", onPaste)
window.removeEventListener("message", onMessage)
}
}, [])
// 언마운트 시 남은 object URL 정리.
const itemsRef = useRef(items)
itemsRef.current = items
useEffect(
() => () => {
for (const it of itemsRef.current) {
if (it.kind === "image") URL.revokeObjectURL(it.url)
}
},
[]
)
// 이력 클릭 → OS 클립보드로 다시 복사(다른 앱에 붙이게).
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("클립보드에 복사됨")
} catch {
toast.error("복사 실패")
}
}
const copyImage = async (blob: Blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
toast.success("이미지 클립보드에 복사됨")
} catch {
toast.error("이미지 복사 실패")
}
}
return (
<div className="border-border flex max-h-72 flex-none flex-col border-b">
<div className="flex flex-none items-center gap-1.5 px-3 py-2.5">
<Clipboard className="text-muted-foreground size-3" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Clipboard
</span>
{items.length > 0 && (
<span className="text-muted-foreground/60 ml-auto font-mono text-[9px]">
{items.length}
</span>
)}
</div>
{items.length === 0 ? (
<p className="border-border text-muted-foreground/60 mx-2.5 mb-2.5 rounded border border-dashed px-2 py-3 text-center font-mono text-[10px] leading-relaxed">
Ctrl+V
<br />
</p>
) : (
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 overflow-y-auto px-1.5 pb-1.5">
{items.map((it) =>
it.kind === "image" ? (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyImage(it.blob)}
title="클릭하면 이미지 클립보드에 복사"
className="border-border hover:border-ring relative overflow-hidden rounded border"
>
<img
src={it.url}
alt="클립보드 이미지"
className="bg-muted/40 max-h-20 w-full object-contain"
/>
<span className="bg-background/80 text-muted-foreground absolute top-1 right-1 rounded px-1 font-mono text-[8px]">
IMG
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 큰 이미지 */}
<TooltipContent side="right" align="start" className="p-1">
<img
src={it.url}
alt="클립보드 이미지 미리보기"
className="max-h-64 max-w-xs rounded object-contain"
/>
</TooltipContent>
</Tooltip>
) : (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyText(it.value)}
title="클릭하면 클립보드에 복사"
className="hover:bg-accent flex items-start gap-1.5 rounded px-2 py-1.5 text-left"
>
<Type className="text-muted-foreground/70 mt-0.5 size-3 flex-none" />
<span className="text-foreground/80 line-clamp-2 font-mono text-[10px] leading-snug break-words whitespace-pre-wrap">
{it.value.slice(0, 160)}
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 전체 텍스트(길면 스크롤) */}
<TooltipContent
side="right"
align="start"
className="max-h-72 max-w-md overflow-auto"
>
<pre className="font-mono text-[11px] leading-snug break-words whitespace-pre-wrap">
{it.value}
</pre>
</TooltipContent>
</Tooltip>
)
)}
</div>
</TooltipProvider>
)}
</div>
)
}
@@ -0,0 +1,163 @@
import { useMemo, useState } from "react"
import { Check, ClipboardPaste, Copy } from "lucide-react"
import { toast } from "sonner"
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import hljs from "highlight.js/lib/core"
import sql from "highlight.js/lib/languages/sql"
import json from "highlight.js/lib/languages/json"
import diff from "highlight.js/lib/languages/diff"
import yaml from "highlight.js/lib/languages/yaml"
import plaintext from "highlight.js/lib/languages/plaintext"
import abap from "./abapHljs"
import "highlight.js/styles/github-dark.css"
import "./abapLight.css"
// 코어 빌드에 필요한 언어만 등록(번들 최소화). ABAP 은 커스텀 문법.
hljs.registerLanguage("sql", sql)
hljs.registerLanguage("json", json)
hljs.registerLanguage("diff", diff)
hljs.registerLanguage("yaml", yaml)
hljs.registerLanguage("plaintext", plaintext)
hljs.registerLanguage("abap", abap)
// 펜스 언어 라벨 → 등록된 문법 매핑.
const ALIAS: Record<string, string> = {
cds: "abap",
ddl: "abap",
abapsql: "abap",
sqlscript: "sql",
text: "plaintext",
txt: "plaintext",
yml: "yaml",
}
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] ?? c)
}
interface Props {
code: string
lang?: string
/** NavRail 점프 대상 식별용 인덱스. */
index?: number
/** 주변 UI가 제목과 액션을 담당하는 독립 프리뷰에서는 코드만 표시함. */
plain?: boolean
}
export function CodeBlock({ code, lang, index, plain = false }: Props) {
const key = (lang ?? "").toLowerCase()
const resolved = ALIAS[key] ?? key
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
const isAbap = resolved === "abap"
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
const isLight = isAbap || resolved === ""
const html = useMemo(() => {
if (resolved && hljs.getLanguage(resolved)) {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
}
return escapeHtml(code)
}, [code, resolved])
const lineCount = useMemo(() => code.replace(/\n$/, "").split("\n").length, [code])
return (
<div
data-code-block={index ?? 0}
data-code-lang={lang ?? "code"}
className={
plain
? `h-full min-h-0 overflow-hidden ${isLight ? "bg-white text-zinc-900" : "bg-[#0d1117] text-zinc-100"}`
: isLight
? "my-2.5 overflow-hidden rounded-lg border border-zinc-300 bg-white text-zinc-900 shadow-sm"
: "my-2.5 overflow-hidden rounded-lg border border-zinc-700/60 bg-[#0d1117] text-zinc-100 shadow-sm"
}
>
{!plain && (
<div
className={
isLight
? "flex items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-1.5"
: "flex items-center gap-2 border-b border-white/5 bg-white/[0.03] px-3 py-1.5"
}
>
<span className="flex gap-1">
<span className="size-2 rounded-full bg-rose-400/70" />
<span className="size-2 rounded-full bg-amber-400/70" />
<span className="size-2 rounded-full bg-emerald-400/70" />
</span>
<span
className={
isLight
? "ml-1 font-mono text-[10px] tracking-wider text-zinc-500 uppercase"
: "ml-1 font-mono text-[10px] tracking-wider text-zinc-400 uppercase"
}
>
#<span data-code-num>{(index ?? 0) + 1}</span> · {lang ?? "code"}
</span>
<CodeActions code={code} isLight={isLight} />
</div>
)}
<div
className={`flex overflow-auto text-xs leading-relaxed ${plain ? "h-full" : "max-h-96"}`}
>
<div
aria-hidden
className={
isLight
? "flex-none border-r border-zinc-200 px-2.5 py-3 text-right font-mono text-zinc-400 select-none"
: "flex-none border-r border-white/5 px-2.5 py-3 text-right font-mono text-zinc-600 select-none"
}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
<code
className={isAbap ? "hljs-abap-light" : undefined}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
</div>
</div>
)
}
/** 코드 카드와 스니펫 하단에서 같은 복사·붙여넣기 동작을 재사용함. */
export function CodeActions({ code, isLight = true }: { code: string; isLight?: boolean }) {
const [copied, setCopied] = useState(false)
// 헤더 버튼 공통 스타일(복사·붙여넣기 공유). ml-auto 는 감싸는 컨테이너가 가짐.
const btnClass = isLight
? "inline-flex items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 font-mono text-[10px] text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900"
: "inline-flex items-center gap-1 rounded border border-zinc-700 px-2 py-0.5 font-mono text-[10px] text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
const copy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopied(false), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<div className="ml-auto flex items-center gap-1.5">
{isWebView() && (
<button
type="button"
onClick={() => pasteToApp(code)}
className={btnClass}
title="런처 소환 직전 앱에 붙여넣기(붙이고 창은 자동으로 숨김)"
>
<ClipboardPaste className="size-3" />
</button>
)}
<button type="button" onClick={copy} className={btnClass}>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "복사됨" : "복사"}
</button>
</div>
)
}
@@ -0,0 +1,82 @@
import { describe, expect, it, afterEach, vi } from "vitest"
import { render, screen, cleanup, act, fireEvent } from "@testing-library/react"
import { Composer } from "./Composer"
import { initBridgeNavigate, consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
type Listener = (e: MessageEvent) => void
function mockWebview() {
let listener: Listener | undefined
;(window as unknown as { chrome?: unknown }).chrome = {
webview: {
postMessage: () => {},
addEventListener: (type: string, cb: Listener) => {
if (type === "message") listener = cb
},
},
}
return { emit: (data: unknown) => listener?.({ data } as MessageEvent) }
}
describe("Composer 캡쳐 이미지 첨부", () => {
afterEach(() => {
cleanup()
delete (window as unknown as { chrome?: unknown }).chrome
})
// 코드리뷰 Critical 회귀 재현: onCapture 가 이벤트 detail 만 읽고 pendingCaptureImage 를
// 안 비우면, 리마운트(다른 대화 갔다가 새 대화 재진입) 시 마운트 이펙트가 옛 캡쳐를 재소비함.
it("리마운트 시 이미 소비된 캡쳐 이미지를 다시 첨부하지 않음", () => {
const wv = mockWebview()
initBridgeNavigate()
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
expect(screen.getAllByRole("img")).toHaveLength(1)
unmount()
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
})
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
const wv = mockWebview()
initBridgeNavigate()
const onSend = vi.fn()
render(<Composer onSend={onSend} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" }))
fireEvent.click(screen.getByRole("button", { name: "전송" }))
expect(onSend).toHaveBeenCalledWith("", [
{ mediaType: "image/png", data: "data:image/png;base64,eA==" },
])
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
it("이미지는 최대 4장까지만 첨부한다", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} acceptCapture />)
for (let i = 0; i < 5; i++) {
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
}
expect(screen.queryAllByRole("img")).toHaveLength(4)
})
})
@@ -0,0 +1,298 @@
import { useEffect, useRef, useState } from "react"
import { BookOpen, ClipboardPaste, Send, Square, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { cn } from "@/lib/utils/cn"
import { consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
import { useSnapChatStore } from "../store/snapChatStore"
import type { SnapImageInput, SnapImageMediaType } from "../contract/types"
interface Props {
onSend: (text: string, images: SnapImageInput[]) => void
busy?: boolean
onStop?: () => void
placeholder?: string
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
acceptCapture?: boolean
}
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result as string)
r.onerror = () => reject(r.error)
r.readAsDataURL(blob)
})
}
// 붙여넣기 텍스트가 이 길이 이상이면 입력창에 안 넣고 접힌 칩(첨부)으로 보관.
const PASTE_COLLAPSE = 100
const IMAGE_TYPES = new Set<SnapImageMediaType>(["image/png", "image/jpeg", "image/webp"])
const MAX_IMAGES = 4
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024
function imageByteLength(dataUrl: string): number {
const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1)
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding)
}
function imageFromDataUrl(data: string): SnapImageInput | null {
const match = /^data:(image\/(?:png|jpeg|webp));base64,/i.exec(data)
if (!match || !IMAGE_TYPES.has(match[1].toLowerCase() as SnapImageMediaType)) return null
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
}
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
const [value, setValue] = useState("")
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
const [attachments, setAttachments] = useState<string[]>([])
// 캡쳐·클립보드에서 받은 일회성 이미지 첨부. 백엔드는 원본을 저장하지 않음.
const [imageAttachments, setImageAttachments] = useState<SnapImageInput[]>([])
const explain = useSnapChatStore((s) => s.explain)
const setExplain = useSnapChatStore((s) => s.setExplain)
const addImage = (dataUrl: string) => {
const image = imageFromDataUrl(dataUrl)
if (!image) {
toast.error("PNG, JPEG, WebP 이미지만 첨부할 수 있어")
return
}
const bytes = imageByteLength(dataUrl)
if (bytes > MAX_IMAGE_BYTES) {
toast.error("이미지는 한 장당 5 MiB 이하여야 해")
return
}
setImageAttachments((current) => {
if (current.length >= MAX_IMAGES) {
toast.error("이미지는 최대 4장까지 첨부할 수 있어")
return current
}
const total = current.reduce((sum, item) => sum + imageByteLength(item.data), 0) + bytes
if (total > MAX_TOTAL_IMAGE_BYTES) {
toast.error("이미지 전체 크기는 15 MiB 이하여야 해")
return current
}
return [...current, image]
})
}
// 마운트 시(새 대화·지난 대화 진입) 입력창에 커서. rAF로 webview 포커스 안정화 후.
const taRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const raf = requestAnimationFrame(() => taRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [])
// 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
useEffect(() => {
if (!acceptCapture) return
const pending = consumePendingCaptureImage()
if (pending) addImage(pending)
const onCapture = () => {
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
const dataUrl = consumePendingCaptureImage()
if (dataUrl) addImage(dataUrl)
}
window.addEventListener("bridge:captureImage", onCapture)
return () => window.removeEventListener("bridge:captureImage", onCapture)
}, [acceptCapture])
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
const removeImageAttachment = (i: number) =>
setImageAttachments((a) => a.filter((_, j) => j !== i))
const submit = () => {
// 접어둔 첨부들 먼저, 그다음 입력값 — 빈 건 빼고 이어붙임.
const combined = [...attachments, value.trim()].filter(Boolean).join("\n\n")
if ((!combined && imageAttachments.length === 0) || busy) return
setValue("")
setAttachments([])
const images = imageAttachments
setImageAttachments([])
onSend(combined, images)
}
// 이 환경이 클립보드 읽기(텍스트+이미지)를 지원하는가 — 비 https·구형 웹뷰면 read 가 없음.
const clipboardSupported =
typeof navigator !== "undefined" && typeof navigator.clipboard?.read === "function"
// 클릭(사용자 제스처)이라 read() 가 먹음. 텍스트·이미지만 지원.
const pasteFromClipboard = async () => {
if (!clipboardSupported) {
toast.error("이 환경은 클립보드 읽기를 지원하지 않아")
return
}
try {
const items = await navigator.clipboard.read()
let gotText = false
let gotImage = false
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith("image/"))
if (imageType) {
const dataUrl = await blobToDataUrl(await item.getType(imageType))
addImage(dataUrl)
gotImage = true
} else if (item.types.includes("text/plain")) {
const text = (await (await item.getType("text/plain")).text()).trim()
if (text) {
// 길면 접힌 칩으로, 짧으면 입력창에 그대로.
if (text.length >= PASTE_COLLAPSE) setAttachments((a) => [...a, text])
else setValue((v) => (v ? `${v}\n${text}` : text))
gotText = true
}
}
}
if (gotImage && !gotText) toast.success("이미지를 질문에 첨부했어")
else if (!gotText && !gotImage) toast.info("클립보드에 텍스트·이미지가 없어")
} catch {
// NotAllowedError 등 — 사용자가 권한을 막았거나 브라우저가 거부.
toast.error("클립보드를 읽을 수 없어 (권한 거부됨)")
}
}
return (
<div className="border-border bg-card flex-none border-t p-3">
<div className="border-border bg-background focus-within:border-ring focus-within:ring-ring/20 rounded-lg border p-2 focus-within:ring-2">
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{imageAttachments.map((image, i) => (
<span
key={i}
className="border-border bg-muted/60 relative flex items-center rounded-md border p-1"
>
<img
src={image.data}
alt={`캡쳐 이미지 ${i + 1}`}
className="h-12 w-12 rounded object-cover"
/>
<button
type="button"
onClick={() => removeImageAttachment(i)}
aria-label="캡쳐 이미지 제거"
className="bg-background border-border text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
{/* 100자↑ 붙여넣기 첨부 — 일부만 보이고 X 로 제거 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="border-border bg-muted/60 text-muted-foreground flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
>
<ClipboardPaste className="size-3.5 flex-none text-emerald-500" />
<span className="min-w-0 truncate font-mono">
{a.replace(/\s+/g, " ").slice(0, 40)}
</span>
<span className="flex-none opacity-50">{a.length}</span>
<button
type="button"
onClick={() => removeAttachment(i)}
aria-label="첨부 제거"
className="hover:text-foreground flex-none"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((file) =>
file.type.startsWith("image/")
)
if (imageFile) {
e.preventDefault()
void blobToDataUrl(imageFile).then(addImage)
return
}
const text = e.clipboardData.getData("text/plain")
if (text.length >= PASTE_COLLAPSE) {
e.preventDefault() // 길면 입력창에 안 넣고 접힌 칩으로
setAttachments((a) => [...a, text])
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
rows={1}
disabled={busy}
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
className="placeholder:text-muted-foreground field-sizing-content max-h-32 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
/>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={pasteFromClipboard}
disabled={busy || !clipboardSupported}
title={clipboardSupported ? "클립보드에서 가져오기" : "이 환경은 클립보드 읽기 미지원"}
className="border-border text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors disabled:opacity-50"
>
<ClipboardPaste className="size-3" />
</button>
<button
type="button"
onClick={() => setExplain(!explain)}
aria-pressed={explain}
title={explain ? "설명 모드 — 배경·원리까지" : "간결 모드 — 답만"}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors",
explain
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
)}
>
<BookOpen className="size-3" />
</button>
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
Enter · Shift+Enter
</span>
{busy ? (
<Button
type="button"
size="sm"
variant="destructive"
className="ml-auto"
onClick={onStop}
>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
onClick={submit}
>
<Send className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,17 @@
export function Hero() {
return (
<section className="flex flex-col items-center px-6 py-10 text-center">
<div className="bg-primary text-primary-foreground mb-4 grid size-14 place-items-center rounded-xl font-serif text-2xl font-bold italic">
S
</div>
<span className="text-muted-foreground mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] tracking-widest uppercase">
<span className="size-1.5 rounded-full bg-emerald-500" />
New Session
</span>
<h1 className="mb-2 font-serif text-2xl font-semibold italic"> ?</h1>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
ABAP · CDS · HANA · .
</p>
</section>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import { render } from "@testing-library/react"
import { Message } from "./Message"
import type { SnapRole } from "../contract/types"
// JSX 에 role 을 문자열 리터럴로 쓰면 jsx-a11y 가 ARIA role 로 오해함 — 변수로 우회
const assistant: SnapRole = "assistant"
describe("Message 코드펜스 렌더", () => {
it("언어 없는 코드펜스(ASCII 다이어그램)도 CodeBlock 으로 렌더된다", () => {
// 언어 라벨 없는 펜스 — 인라인 code 로 새면 ─── 연속 문자가 말풍선 밖으로 넘침
const content = [
"구조 제안:",
"",
"```",
"┌──────────────────────────────┐",
"│ ABAP Productivity App │",
"└──────────────────────────────┘",
"```",
].join("\n")
const { container } = render(<Message role={assistant} content={content} />)
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
const block = container.querySelector("[data-code-block]")
expect(block).not.toBeNull()
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
expect(block?.className).toContain("bg-white")
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
expect(container.querySelector("code.rounded")).toBeNull()
})
it("언어 있는 코드펜스는 기존대로 CodeBlock + 언어 라벨", () => {
const content = "```sql\nSELECT * FROM t;\n```"
const { container } = render(<Message role={assistant} content={content} />)
expect(container.querySelector('[data-code-lang="sql"]')).not.toBeNull()
})
it("인라인 code 는 그대로 인라인으로 렌더된다", () => {
const { container } = render(<Message role={assistant} content="이건 `SMOINT` 임" />)
expect(container.querySelector("code.rounded")).not.toBeNull()
expect(container.querySelector("[data-code-block]")).toBeNull()
})
})
@@ -0,0 +1,209 @@
import { isValidElement, type ReactElement, type ReactNode } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { MoreHorizontal, Copy, Download } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils/cn"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/shared/ui/dropdown-menu"
import type { SnapRole } from "../contract/types"
import { CodeBlock } from "./CodeBlock"
import { fmtElapsed, fmtTokens } from "../lib/format"
interface Props {
role: SnapRole
content: string
/** 이 답변 전체토큰(assistant). 있으면 버블 밑에 표기. */
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브만 있음(과거는 elapsed 미저장). */
elapsedMs?: number
}
// assistant 마크다운 본문 prose 스타일 — 목업이 표/헤딩/리스트/diff 를 섞어 써서 각 요소 명시 스타일링.
const PROSE = cn(
"first:[&>*]:mt-0 last:[&>*]:mb-0",
"[&_p]:my-1.5 [&_p]:leading-[1.9]",
"[&_strong]:font-semibold [&_strong]:text-foreground",
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
"[&_blockquote]:my-1.5 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
"[&_th]:border [&_th]:border-border [&_th]:bg-foreground/5 [&_th]:px-2.5 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
"[&_td]:border [&_td]:border-border [&_td]:px-2.5 [&_td]:py-1"
)
export function Message({ role, content, totalTokens, elapsedMs }: Props) {
const isUser = role === "user"
let local = 0
// 답변 본문(마크다운) 복사 / .md 다운로드.
const copyMd = async () => {
try {
await navigator.clipboard.writeText(content)
toast.success("복사됨 (마크다운)")
} catch {
toast.error("복사 실패")
}
}
const downloadMd = () => {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "snap-답변.md"
document.body.appendChild(a) // Firefox/일부 Chrome 은 DOM 에 붙어야 클릭 먹음
a.click()
a.remove()
// 즉시 revoke 하면 다운로드 시작 전에 blob 이 사라져 ERR_FILE_NOT_FOUND — 한 틱 미룸
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
return (
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
<div className={cn("flex items-center gap-1.5", !isUser && "w-full")}>
{!isUser && (
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded font-serif text-[9px] leading-none font-bold italic">
S
</span>
)}
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
{isUser ? "나 · YOU" : "SNAP MATE"}
</span>
{!isUser && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="답변 메뉴"
className="text-muted-foreground hover:bg-accent hover:text-foreground ml-auto inline-flex size-6 items-center justify-center rounded transition-colors"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyMd}>
<Copy className="size-3.5" />
(MD)
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadMd}>
<Download className="size-3.5" />
(MD)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div
className={cn(
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
isUser
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
: "border-border bg-card text-card-foreground w-full border"
)}
>
{isUser ? (
content
) : (
<div className={PROSE}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 코드펜스는 언어 유무와 무관하게 전부 CodeBlock 으로.
// pre 를 그냥 언랩하면 언어 없는 펜스(ASCII 다이어그램 등)가
// 인라인 code 로 렌더돼 ─── 연속 문자가 말풍선 밖으로 넘침.
pre: ({ children }) => {
const child = isValidElement(children)
? (children as ReactElement<{ className?: string; children?: ReactNode }>)
: null
if (!child) return <pre>{children}</pre>
const match = /language-(\w+)/.exec(child.props.className ?? "")
const index = local++
return (
<CodeBlock
code={String(child.props.children ?? "").replace(/\n$/, "")}
lang={match?.[1]}
index={index}
/>
)
},
// 헤딩·구분선 여백은 inline style 로 직접 박음(Tailwind 재생성/캐시 이슈 회피).
// top 마진 크게 줘서 앞 섹션이랑 확실히 떨어짐.
h1: ({ children }) => (
<h1
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1.125rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
style={{
marginTop: "1.25rem",
marginBottom: "0.375rem",
fontSize: "0.9375rem",
fontWeight: 600,
lineHeight: 1.3,
}}
>
{children}
</h3>
),
hr: () => (
<hr
style={{
marginTop: "1rem",
marginBottom: "1rem",
border: 0,
borderTop: "1px solid var(--border)",
}}
/>
),
table: ({ children }) => (
<div className="my-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
// 블록 코드는 위 pre 에서 다 처리되니 여기 오는 건 인라인뿐.
code: ({ children }) => (
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
}}
>
{content}
</ReactMarkdown>
</div>
)}
</div>
{!isUser && totalTokens != null && (
<span className="text-muted-foreground/60 px-1 font-mono text-[9px] tabular-nums">
{fmtTokens(totalTokens)} tok
{elapsedMs != null && ` · ${fmtElapsed(elapsedMs)}`}
</span>
)}
</div>
)
}
@@ -0,0 +1,93 @@
import { useState } from "react"
import { Check, Code2, Copy } from "lucide-react"
import { toast } from "sonner"
import { ClipboardHistory } from "./ClipboardHistory"
interface Block {
index: number
lang: string
}
interface Props {
/** 렌더된 코드블럭 목록(DOM 순서, index=data-code-block 과 동일). */
blocks: Block[]
/** 스트림 컨테이너 ref — 코드블럭으로 스크롤 점프. */
containerRef: React.RefObject<HTMLDivElement>
}
export function NavRail({ blocks, containerRef }: Props) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
const jump = (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(`[data-code-block="${index}"]`)
el?.scrollIntoView({ behavior: "smooth", block: "center" })
}
// 점프와 같은 방식으로 해당 코드블럭 DOM 을 찾아 실제 코드 텍스트를 복사.
const copy = async (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(
`[data-code-block="${index}"] pre code`
)
const text = el?.textContent
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopiedIdx(index)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopiedIdx(null), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<aside className="border-border bg-card hidden w-52 flex-none flex-col border-r min-[540px]:flex">
<ClipboardHistory />
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav
</span>
</div>
{blocks.length === 0 ? (
<div className="text-muted-foreground p-4 font-mono text-[10px] leading-relaxed">
<br />
<br />
.
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-1.5">
{blocks.map((b) => (
<div key={b.index} className="group hover:bg-accent flex items-center rounded">
<button
type="button"
onClick={() => jump(b.index)}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-2 py-1.5 text-left font-mono text-[11px]"
>
<Code2 className="text-muted-foreground size-3 flex-none" />
<span className="text-muted-foreground flex-none tabular-nums">#{b.index + 1}</span>
<span className="truncate">{b.lang}</span>
</button>
<button
type="button"
onClick={() => copy(b.index)}
aria-label="코드 복사"
title="코드 복사"
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
>
{copiedIdx === b.index ? (
<Check className="size-3 text-emerald-500" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
))}
</div>
)}
</aside>
)
}
@@ -0,0 +1,60 @@
import { CornerDownRight, MessageSquare } from "lucide-react"
import type { ReactNode } from "react"
import type { SnapMessage } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
hit: SnapMessage
query: string
onOpen: (sessionId: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
function snippet(text: string, query: string): ReactNode {
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx < 0) return text.slice(0, 120)
const start = Math.max(0, idx - 40)
const end = Math.min(text.length, idx + query.length + 80)
return (
<>
{start > 0 && "…"}
{text.slice(start, idx)}
<mark className="bg-primary/20 text-foreground rounded px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length, end)}
{end < text.length && "…"}
</>
)
}
export function SearchHitCard({ hit, query, onOpen, selected }: Props) {
return (
<button
type="button"
onClick={() => onOpen(hit.sessionId)}
data-snap-selected={selected ? "true" : undefined}
className={`focus-visible:ring-ring flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<MessageSquare className="text-muted-foreground mt-0.5 size-4 flex-none" />
<span className="flex min-w-0 flex-col">
<span className="line-clamp-2 text-xs leading-relaxed break-words whitespace-pre-wrap">
{snippet(hit.content, query)}
</span>
<span className="text-muted-foreground mt-1.5 flex items-center gap-2 font-mono text-[9.5px]">
<span className="tracking-wide uppercase">
{hit.role === "user" ? "나" : "SNAP MATE"}
</span>
<span>·</span>
<span>{formatRelativeKo(hit.createdAt)}</span>
<CornerDownRight className="size-3" />
<span> </span>
</span>
</span>
</button>
)
}
@@ -0,0 +1,43 @@
import { MessageSquare, ChevronRight } from "lucide-react"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
session: SnapSession
onOpen: (id: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
export function SessionCard({ session, onOpen, selected }: Props) {
const title = session.titleLlm ?? session.title ?? "제목 없음"
return (
// 컴팩트 1행 — 세로를 1/3로. 제목·태그·시간·상태를 한 줄에, 스니펫은 생략(제목 title 로 노출).
<button
type="button"
onClick={() => onOpen(session.id)}
data-snap-selected={selected ? "true" : undefined}
title={session.snippet ?? title}
className={`focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<span className="bg-muted text-muted-foreground grid size-8 flex-none place-items-center rounded-lg">
<MessageSquare className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
{session.tag && (
<span className="border-border text-muted-foreground flex-none rounded border px-1.5 font-mono text-[9.5px] tracking-wide uppercase">
{session.tag}
</span>
)}
{session.isGenerating && (
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
)}
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
{formatRelativeKo(session.updatedAt)}
</span>
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
</button>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react"
import { Search, X } from "lucide-react"
interface Props {
value: string
onChange: (v: string) => void
}
export function SessionSearch({ value, onChange }: Props) {
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
const focus = () => ref.current?.focus()
focus()
window.addEventListener("focus", focus)
return () => window.removeEventListener("focus", focus)
}, [])
return (
<div className="relative flex min-w-0 flex-1 items-center">
<Search className="text-muted-foreground pointer-events-none absolute left-0 size-5" />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label="대화 내용 검색"
placeholder="대화 내용 검색…"
className="placeholder:text-muted-foreground/75 focus-visible:ring-ring/40 w-full rounded-md bg-transparent py-3 pr-9 pl-9 text-lg outline-none focus-visible:ring-2"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring absolute right-1 rounded p-1 focus-visible:ring-2"
onClick={() => {
onChange("")
ref.current?.focus()
}}
>
<X className="size-4" />
</button>
)}
</div>
)
}
@@ -0,0 +1,45 @@
import { hostKind } from "@/lib/bridge/transport"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
import { SnapUserControls } from "./SnapUserControls"
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
export function SnapLayout() {
const navigate = useNavigate()
// Ctrl+N → 새 대화(앱 안 어디서나). 브라우저 기본동작(새 창)은 막음.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "N")) {
e.preventDefault()
navigate(PATHS.SNAP_NEW)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate])
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 헤더 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Chat Everywhere
</span>
</header>
)}
<SnapUserControls />
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
)
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { SnapUserControls } from "./SnapUserControls"
const mutate = vi.fn()
vi.mock("@/features/auth/hooks/useLogout", () => ({
useLogout: () => ({ mutate, isPending: false }),
}))
vi.mock("@/shared/components/ThemeToggle", () => ({
ThemeToggle: () => <button type="button"> </button>,
}))
describe("SnapUserControls", () => {
beforeEach(() => mutate.mockClear())
it("사이드바 없이 테마 변경과 로그아웃을 표시", () => {
render(
<MemoryRouter>
<SnapUserControls />
</MemoryRouter>
)
expect(screen.getByRole("button", { name: "테마 변경" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "로그아웃" }))
expect(mutate).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,35 @@
import { LogOut } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { useLogout } from "@/features/auth/hooks/useLogout"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { Button } from "@/shared/ui/button"
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
export function SnapUserControls() {
const navigate = useNavigate()
const logout = useLogout()
const handleLogout = () => {
logout.mutate(undefined, {
onSettled: () => navigate(PATHS.LOGIN, { replace: true }),
})
}
return (
<div className="border-border bg-background/90 fixed top-11 right-3 z-40 flex items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur">
<ThemeToggle />
<Button
type="button"
variant="ghost"
size="icon"
aria-label="로그아웃"
title="로그아웃"
disabled={logout.isPending}
onClick={handleLogout}
>
<LogOut className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
@@ -0,0 +1,34 @@
import type { HLJSApi, Language } from "highlight.js"
// highlight.js 코어엔 ABAP 이 없어서 경량 문법을 직접 등록한다.
// ABAP OO + Open SQL + CDS(DDL) + RAP behavior 를 한 문법으로 커버 — 목업 렌더용이라 완벽 파서는 아님.
export default function abap(hljs: HLJSApi): Language {
const KEYWORDS =
"select from into table where and or not as inner left right outer join on group by " +
"order having distinct up to rows loop at endloop do enddo while endwhile if elseif " +
"else endif case when others endcase data types constants field-symbols read append " +
"modify insert update delete clear refresh sort binary search for all entries in " +
"package size cond switch value new corresponding lines of exporting importing changing " +
"returning raising method endmethod class endclass public protected private section " +
"define view entity projection root key managed unmanaged implementation unique strict " +
"behavior persistent lock master authorization instance create determination validation " +
"association service expose annotate with begin end of is initial single"
return {
name: "ABAP",
case_insensitive: true,
keywords: {
keyword: KEYWORDS,
built_in: "sy-subrc sy-tabix sy-index sy-datum sy-uzeit abap_true abap_false",
},
contains: [
hljs.COMMENT("^\\*", "$"), // 전체 줄 주석 (* 로 시작)
hljs.COMMENT('"', "$"), // 인라인 주석 (")
{ className: "string", begin: "'", end: "'" },
{ className: "string", begin: "`", end: "`" },
{ className: "string", begin: "\\|", end: "\\|" }, // 문자열 템플릿 |...|
{ className: "meta", begin: "@[A-Za-z][\\w.]*" }, // @UI.lineItem / @DATA 등
hljs.C_NUMBER_MODE,
],
}
}
@@ -0,0 +1,26 @@
/* SAP ABAP 에디터 : 배경 + 파란 키워드 + 검정 본문.
github-dark 전역 .hljs-* 색을 ABAP 블록에만 스코프로 덮어쓴다
(.hljs-abap-light .hljs-keyword specificity 전역 규칙을 이긴다). */
.hljs-abap-light {
color: #1a1a1a;
}
.hljs-abap-light .hljs-keyword {
color: #0033b3;
font-weight: 600;
}
.hljs-abap-light .hljs-built_in {
color: #0e7490;
}
.hljs-abap-light .hljs-string {
color: #a31515;
}
.hljs-abap-light .hljs-comment {
color: #6b7280;
font-style: italic;
}
.hljs-abap-light .hljs-meta {
color: #7a3e9d;
}
.hljs-abap-light .hljs-number {
color: #098658;
}
@@ -0,0 +1,47 @@
// base-backend modules/chat/schema.py 와 1:1 (camelCase). UI-only 필드는 명시 표기.
export type SnapRole = "user" | "assistant" | "system"
export interface SnapSession {
id: string
title: string | null
titleLlm: string | null
isGenerating: boolean
createdAt: string
updatedAt: string
// --- UI-only: 백엔드에 없음. real 스왑 시 드롭 또는 파생(snippet=마지막메시지) ---
tag?: string
snippet?: string
tokens?: string
}
export interface SnapMessage {
sessionId: string
role: SnapRole
content: string // markdown
createdAt: string
// usage/timing — assistant 행에만 채워짐(user 는 null). 전체토큰 = input+output.
inputTokens?: number | null
outputTokens?: number | null
costUsd?: number | null
elapsedMs?: number | null
}
export interface SnapSessionDetail extends SnapSession {
messages: SnapMessage[]
}
export type SnapImageMediaType = "image/png" | "image/jpeg" | "image/webp"
export interface SnapImageInput {
mediaType: SnapImageMediaType
data: string
}
export interface SnapStreamRequest {
sessionId: string
content: string
images?: SnapImageInput[]
forcedSkill?: string
// 설명 모드 토글 — true면 배경·원리까지, 기본(false)은 간결.
explain?: boolean
}
@@ -0,0 +1,17 @@
import { useEffect, useRef } from "react"
/**
* Esc . (Radix ) Esc(defaultPrevented)
* . handler ref .
*/
export function useEscapeKey(handler: () => void) {
const ref = useRef(handler)
ref.current = handler
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) ref.current()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
}

Some files were not shown because too many files have changed in this diff Show More