Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/queuing-ui-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Do not use it for API-only changes unless the UI behavior also changes.
- Room list/card callbacks should not create per-item inline closures in parent `map` calls when the card can own the click context; pass the room/item and a stable handler into the card and bind inside the card.
- Wheel navigation that must call `preventDefault()` must use a DOM ref plus native `addEventListener("wheel", handler, { passive: false })`; do not rely on JSX `onWheel` for scroll cancellation.
- Text inputs or textareas that submit on Enter must ignore Enter during IME composition using `nativeEvent.isComposing`, `keyCode === 229`, or a composition ref before calling submit.
- Debounced live search must not wait indefinitely for `compositionend`: debounce the visible input value and reserve composition guards for keyboard submission/selection. Test a composing input that stops changing without Enter or blur and verify results arrive without repeated idle requests.
- Queue reordering lets owners move every pending entry, and the personal queue lets users move every one of their pending entries. `ownerOrdered` is display-only server state and must not disable drag or remove an entry from personal reorder payloads.
- For virtualized dnd-kit sortable lists, prefer dragging the original row. Add a `DragOverlay` only with a repeated pointer-drop check that proves every original row remains visible and has no retained opacity, transform, or animation; keep drag transforms out of the card's generic CSS transition.
- Room reconnect UI must return to an explicit joining state, repeat the backend join handshake with the saved room access token, then restore token-authenticated chat/room subscriptions and server reads. Topic resubscription alone is not a joined room session, and a password change must not interrupt an already joined token holder.
Expand Down
71 changes: 71 additions & 0 deletions docs/agent-harness/incidents/2026-09-13-track-search-ime-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 한글 조합 종료를 기다리던 노래 제목 자동 검색

## Problem

사용자는 제목 검색이 엔터 없이 계속 검색 중으로 남고 엔터 후 약 0.5초 내 끝난다고 보고했다.

## Previous Behavior

TrackSearchInput은 compositionstart/end를 state와 ref로 보관했다. useTrackSuggestions는 그 state가 참이면 검색어를 빈 문자열로 디바운스하고 요청을 비활성화하면서 loading은 참으로 반환했다.

## Previous Code

```ts
const debouncedQuery = useDebouncedValue(composing ? "" : input.query, 300);
const enabled = Boolean(userSlug) && open && !composing;
// loading: composing || debouncePending || query.isFetching
```

## Updated Code

```ts
const debouncedQuery = useDebouncedValue(input.query, 300);
const enabled = Boolean(userSlug) && open;
// compositionRef/nativeEvent.isComposing/keyCode 229 still guard keyboard selection.
```

## Problem in the Previous Code

입력값이 더는 변하지 않아도 마지막 한글이 조합 중이면 compositionend가 오지 않을 수 있다. 자동 조회를 조합 종료 이벤트에 종속시키면 실제 요청 없이 검색 중 UI가 무기한 유지된다. Enter 선택 방어와 읽기 전용 자동 조회에 같은 차단 조건을 사용한 것이 문제다.

## Evidence

- compositionStart → 한국어 change → 400ms 경과 시 이전 코드는 새 검색을 호출하지 않는다.
- 50ms 간격 40회 change 후 300ms 유휴 시 이전 코드는 검색 0회, 수정 후 1회.
- 이전 코드에서 회귀 2건 실패를 확인한 뒤 수정했다.
- 수정 후 compositionend/Enter 없이 결과가 표시되고 60초 유휴에도 총 검색 1회다.
- 활성 결과가 있어도 조합 중 Enter(native flag 및 composition ref)는 URL 선택을 하지 않는다.
- 기존 URL 차단, 역순 응답 무시·취소, 계정 분리, quota 실패 테스트를 유지한다.
- 네트워크는 API 함수 mock을 사용했다. 실제 사용자 OS IME와 운영 API 응답 시간은 관측하지 않았다.

## Cause or Remaining Hypotheses

조합 상태에서 요청을 차단하고 무기한 loading을 표시하는 프런트 경로는 재현으로 확인했다. 사용자 환경의 실제 이벤트 순서와 엔터 후 약 0.5초의 서버 응답 시간은 미확인이다. 서버가 느리다고 단정하지 않는다.

## Solution Options

- compositionend만 기다리기: 현재 증상을 유지하므로 제외.
- 조합 ref를 타이머로 강제 해제하기: 조합 중 Enter의 의도치 않은 선택 위험 때문에 제외.
- 입력값 디바운스와 키보드 조합 방어 분리: 선택.

## Chosen Solution and Rationale

자동 조회는 300ms 값 안정화로 실행하고 조합 ref는 키보드 동작에만 사용한다. 기존 cache/AbortSignal/오류 및 URL 분류를 유지하는 작은 변경이다. 조합 도중 300ms 이상 쉬면 미완성 검색어로도 요청될 수 있다는 절충은 있으며 연속 입력은 한 요청으로 합친다.

## Result

조합 종료/엔터 없이도 마지막 입력의 결과를 받을 수 있다. 고정된 네트워크 완료 시간을 보장하는 변경은 아니다.

## Reusable Rule

라이브 검색은 입력 정지에 반응하고, IME 조합 상태는 키보드 제출/선택 방어에 사용한다. 테스트에서 compositionend를 생략해 무기한 대기를 검증한다.

## Skill or Team Spec Updates

- `.agents/skills/queuing-ui-flow/SKILL.md`에 조합 종료 없는 자동 검색과 유휴 요청 상한 검증 규칙을 추가했다.

## Verification

- targeted: 2 files / 9 tests passed.
- 전체 lint/test/build 및 독립 QA 결과는 `docs/exec-plans/active/2026-09-13-profile-search-fix/qa-report.md`에 기록한다.
- Browser runtime bootstrap과 discovery 결과 연결 가능한 브라우저 없음. 실제 화면·OS IME 검증은 미실행.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Delivery State

- status: ci-pending
- branch: dev
- base: main
- issue: none
- pr: https://github.com/Queuing-org/frontend/pull/63
- selected_skills: plan.md 참조
- local_qa: pass — lint / 714 tests / build / 독립 리뷰
- ci: pending
- review_threads: none
- next_action: PR #63 CI 및 실제 화면/OS IME 검증 확인
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Handoff

- 요청 범위 구현 및 dev 게시 완료: https://github.com/Queuing-org/frontend/pull/63 (Draft).
- 기능 커밋: ba921c7 (프로필 행 높이), a4ccfcc (한글 자동 검색).
- lint / 160 files 714 tests / build / 독립 QA pass. 첫 sandbox 빌드는 진행 정체로 중단 후 제한 밖 동일 명령 통과.
- 다음: PR CI와 실제 화면 정렬·OS IME 확인. 연결 가능한 Browser가 없어 화면/네트워크 실측은 미실행.
- 원인: composition 상태가 조회 enabled를 막고 loading을 강제했다. 입력값 디바운스와 Enter 선택 방어를 분리했다.
12 changes: 12 additions & 0 deletions docs/exec-plans/active/2026-09-13-profile-search-fix/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 프로필 통계 정렬과 한글 제목 검색 대기 수정

- 요청: 이용 시간을 큐잉 횟수 높이에 맞추고, 엔터 없이는 노래 검색이 완료되지 않는 원인을 확인한다.
- branch: dev / base: main
- selected_skills: queuing-feature-delivery, queuing-orchestrator, queuing-ui-flow, queuing-api-boundary, frontend-architecture-guardrails, queuing-qa-reviewer, queuing-incident-curator, browser:control-in-app-browser
- 소유권: 공유 프로필 CSS가 통계 배치, playlist query hook이 검색 디바운스, 입력 컴포넌트가 IME 키보드 선택 방어를 소유한다. API 계약과 mutation은 변경하지 않는다.
- 수용 기준: 짧은 최애곡에서 이용 시간이 기존 큐잉 횟수 위치와 정렬되고 긴 문구에도 양쪽 통계가 같은 행에 있다. 조합 종료/엔터 없이 300ms 입력 정지 후 검색한다. 조합 중 Enter는 선택하지 않는다. 취소/오래된 응답/URL/에러 동작을 유지한다.
- 커밋 순서: 1. fix(profile): 프로필 통계의 행 높이를 맞춤 2. fix(playlist): 한글 조합 종료를 기다리던 자동 검색 수정 (회귀 테스트와 실행·사고 기록 포함).
- 검증: 검색 재현 테스트를 먼저 실행해 기존 실패 확인 → 수정 후 targeted test, lint, 전체 test, build → 독립 read-only QA → commit/push/draft PR.
- 진행: 구현 완료. 수정 전 회귀 실패 확인, targeted 9 tests / 전체 714 tests / lint / build / 독립 QA pass. dev 게시 및 Draft PR #63 생성 완료. CI 대기.
- 커밋: ba921c7 (프로필), a4ccfcc (검색·테스트·원인 기록).
- 잔여 위험: 운영 API 응답 시간 및 실제 OS IME 이벤트는 별도 관측하지 않음.
27 changes: 27 additions & 0 deletions docs/exec-plans/active/2026-09-13-profile-search-fix/qa-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# QA Report

- 독립 reviewer: qa_profile_search (read-only)
- 코드 판정: pass — 차단 이슈 없음.
- 원 요청: 이용 시간을 큐잉 횟수 높이에 정렬하고 엔터 없이 지속되는 제목 검색의 원인을 파악.

## 경계 검토

- 프로필: 두 statsColumn이 동일한 3행 subgrid를 공유하며 최애곡의 강제 2lh 공간을 제거했다. 짧은 문구의 이용 시간은 기존 큐잉 횟수 높이로 올라간다. 긴 문구는 양쪽 둘째 행을 함께 밀어 겹침과 불일치를 막는다.
- 검색: 조합 종료와 자동 검색을 분리하고 입력값 300ms 디바운스를 유지했다. 조합 ref / native isComposing / keyCode 229 키보드 선택 방어는 유지한다.
- API/캐시: endpoint, query key, 계정 분리, cache staleTime, AbortSignal, URL 분류, 오류 처리는 변경하지 않았다. mutation 영향 없음.
- 회귀: 조합 종료 없이 결과 표시, 40회 입력 버스트 이후 1회 요청, 60초 유휴 재요청 없음.

## 실행 근거

- 수정 전 회귀 2건 실패: 조합 중 새 검색이 호출되지 않음.
- targeted: 2 files / 9 tests passed.
- npm run lint: pass, 경고 없음.
- npm run test: 160 files / 714 tests passed.
- npm run build: 첫 sandbox 실행은 컴파일 시작 이후 진행 출력이 없어 중단, 제한 밖 동일 명령 재실행 pass.
- git diff --check: pass.

## 잔여 제한

- Browser 초기화 후 discovery가 []라 실제 화면 좌표 및 OS IME는 미검증.
- 운영 API 응답 시간은 미관측. 300ms는 검색 시작 디바운스이며 완료 시간 보장이 아니다.
- 기존 취소 테스트는 교체 중 abort를 검증하지만 닫기 중 in-flight abort를 직접 assert하지 않는다. 이번 변경에서 취소 경로는 유지됨.
2 changes: 2 additions & 0 deletions docs/exec-plans/active/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Active Execution Plans

- [2026-09-13-profile-search-fix](./2026-09-13-profile-search-fix/plan.md): ci-pending — Draft PR #63, 프로필 이용 시간 정렬·한글 자동 검색 대기 수정, lint/714 tests/build/독립 QA 통과

- [2026-09-08-track-search-realtime](./2026-09-08-track-search-realtime/plan.md): ci-pending — Draft PR #62, 노래 검색·친구 UI·큐 중복·실시간 닉네임 통합 개선, 713 tests/lint/build/독립 QA 통과

- [2026-09-05-room-queue-modal-polish](./2026-09-05-room-queue-modal-polish/plan.md): ci-pending — Draft PR #61, 내 노래 현재곡 중복·신청 폼 간격·방 modal dim/높이·Friends 상세 padding 교정 및 로컬 QA 통과
Expand Down
42 changes: 40 additions & 2 deletions src/features/playlist/add-track/ui/TrackSearchInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe("TrackSearchInput", () => {
).toHaveAttribute("aria-selected", "true");
client.clear();
});
it("300ms 디바운스, 조합 중 요청·Enter 차단, 잘못된 URL 제목 검색 차단", async () => {
it("300ms 디바운스와 조합 중 자동 검색, Enter 선택·잘못된 URL 검색 차단", async () => {
vi.useFakeTimers();
const { input, client } = setup();
fireEvent.change(input, { target: { value: "아이유" } });
Expand All @@ -123,9 +123,20 @@ describe("TrackSearchInput", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(searchYouTubeVideos).toHaveBeenCalledTimes(1);
expect(searchYouTubeVideos).toHaveBeenCalledTimes(2);
expect(searchYouTubeVideos).toHaveBeenLastCalledWith(
"아이유 좋은",
expect.any(AbortSignal),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
fireEvent.mouseEnter(screen.getByRole("option"));
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(input).toHaveValue("아이유 좋은");
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("아이유 좋은");
fireEvent.compositionEnd(input);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
Expand All @@ -141,6 +152,33 @@ describe("TrackSearchInput", () => {
expect(searchYouTubeVideos).toHaveBeenCalledTimes(2);
client.clear();
});
it("조합 종료 없이 긴 입력 버스트를 검색 한 번으로 합치고 유휴 중 재요청하지 않는다", async () => {
vi.useFakeTimers();
const { input, client } = setup();
fireEvent.compositionStart(input);
for (let index = 1; index <= 40; index += 1) {
fireEvent.change(input, { target: { value: "가".repeat(index) } });
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
}
expect(searchYouTubeVideos).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(searchYouTubeVideos).toHaveBeenCalledTimes(1);
expect(searchYouTubeVideos).toHaveBeenLastCalledWith(
"가".repeat(40),
expect.any(AbortSignal),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(searchYouTubeVideos).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
client.clear();
});
it("검색 교체·닫기 시 취소하고 역순 응답을 새 결과로 표시하지 않는다", async () => {
let finishOld!: (value: (typeof video)[]) => void;
let oldSignal!: AbortSignal;
Expand Down
5 changes: 1 addition & 4 deletions src/features/playlist/add-track/ui/TrackSearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default function TrackSearchInput({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [composing, setComposing] = useState(false);
const compositionRef = useRef(false);
const [active, setActive] = useState(-1);
const [position, setPosition] = useState({
Expand All @@ -37,7 +36,7 @@ export default function TrackSearchInput({
width: 0,
maxHeight: 0,
});
const suggestions = useTrackSuggestions(value, open && !disabled, composing);
const suggestions = useTrackSuggestions(value, open && !disabled);
const visible =
open &&
!disabled &&
Expand Down Expand Up @@ -161,12 +160,10 @@ export default function TrackSearchInput({
onBlur={() => setOpen(false)}
onCompositionStart={() => {
compositionRef.current = true;
setComposing(true);
setActive(-1);
}}
onCompositionEnd={() => {
compositionRef.current = false;
setComposing(false);
}}
onKeyDown={(event) => {
if (
Expand Down
13 changes: 5 additions & 8 deletions src/features/playlist/model/useTrackSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,15 @@ import {
import { classifyTrackInput } from "./trackSuggestions";
import { trackSuggestionKeys } from "./trackSuggestionKeys";

export function useTrackSuggestions(
value: string,
open: boolean,
composing: boolean,
) {
export function useTrackSuggestions(value: string, open: boolean) {
const queryClient = useQueryClient();
const me = useMe();
const userSlug = !me.isError ? me.data?.slug ?? null : null;
const input = classifyTrackInput(value);
const debouncedQuery = useDebouncedValue(composing ? "" : input.query, 300);
const enabled = Boolean(userSlug) && open && !composing;
// IME can keep the last syllable composing after typing stops.
// Debounce text changes; composition only guards keyboard selection in the UI.
const debouncedQuery = useDebouncedValue(input.query, 300);
const enabled = Boolean(userSlug) && open;
const frequent = useQuery({
queryKey: trackSuggestionKeys.frequent(userSlug),
queryFn: ({ signal }) => fetchFrequentTracks(signal),
Expand Down Expand Up @@ -62,7 +60,6 @@ export function useTrackSuggestions(
? query.data ?? []
: [],
loading:
composing ||
(input.kind === "search" && input.query !== debouncedQuery) ||
query.isFetching,
error: query.error,
Expand Down
12 changes: 6 additions & 6 deletions src/features/user/profile/ui/UserProfileContent.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,17 @@

display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(3, auto);
row-gap: var(--profile-stat-row-gap);
column-gap: 18px;
margin-top: 24px;
}
.statsColumn {
display: flex;
display: grid;
grid-row: 1 / -1;
grid-template-rows: subgrid;
min-width: 0;
flex-direction: column;
gap: var(--profile-stat-row-gap);
align-items: start;
}
.card {
min-width: 0;
Expand Down Expand Up @@ -129,9 +132,6 @@
.favoriteSongValueSlot {
min-height: 1lh;
}
.favoriteSongValueSlot[data-line-clamp="2"] {
min-height: 2lh;
}
.musicPowerRow {
min-width: 0;
}
Expand Down