Skip to content

feat(app): 업무판 — the company's task board, dragged by hand, and a room's "알아보는 중" - #268

Open
yulmucoffee wants to merge 5 commits into
laf/hub-uifrom
laf/hub-tasks-ui
Open

yulmucoffee wants to merge 5 commits into
laf/hub-uifrom
laf/hub-tasks-ui

Conversation

@yulmucoffee

Copy link
Copy Markdown
Collaborator

Stacked on #256 (laf/hub-ui, 회사, itself on #249 → #247). This branch starts from #256's head 68499a8b; until those land the diff against main shows their commits too. The task shapes are #253's (laf/hub-tasks), the room "working" rule is #259's (laf/room-answers), and the note codes are the ones #260 (laf/task-pickup) writes — none of those three is merged here; see "For the merge".

What this changes

업무판 — the company's task board, in 회사. A fourth part beside 방 · 파일 · 스킬 · 멤버 (/company/tasks, its own route file company.tasks.tsx, drawn by company.tsx under the same title and tabs on the wide measure; 회사 in the top row stays lit on it). Five columns: 할 일 · 하는 중 · 검토 · 막힘 · 끝, each in rank order.

  • Drag, our own code, pointer events only (components/tasks/use-board-drag.ts, about 200 lines; no package). A mouse drags the whole card; a finger drags the card's grip (the grip alone is touch-action: none, so a finger elsewhere scrolls the board). The card stays in place, dimmed, while a copy follows the pointer (pointer-events: none, positioned by two CSS variables set from the handlers, not from render); the column under the pointer is read with elementFromPoint and the landing place from the cards' vertical middles (dropIndex), drawn as a line. A press that moves under 5 px is a click and opens the task; the pointer is captured only once the press becomes a drag, so a drag's release never clicks the title. Escape puts the card back. Near the board's left or right edge a drag scrolls it.
  • The keyboard's way: every card has a menu button ({title} 옮기기): 옮길 곳 — the other four columns — and 위로 / 아래로. A menu move says ○○(으)로 옮겼어요. in the board's status line.
  • One move is one write. The card takes the rank halfway between its new neighbours (top: one less than the first, bottom: one more than the last; rankBetween, placeTask), sent with the version the board read: PATCH {version, stage, rank}. It shows at once (optimistic); the hub's answer brings the task back with its new version. 409 laf:task_stale (a run or a colleague moved it first) reads the board again and says 방금 다른 사람이 이 업무를 바꿨어요. 업무판을 새로 불러왔어요.; any other refusal puts the card back and says why. A card's next drag waits for its last move's answer.
  • Live by name. hub.task.changed / hub.task.assigned frames make the board and that task's detail stale (hubKeysForFrame); nothing polls. task.changed and task.assigned join HUB_EVENT_TYPES, which the VM's line filters by.
  • 새 업무: a title and 누가 하나요 — 아직 안 정함, 내 라피 (bot:<me>), a teammate (m:<id>, 나 first), or one of this person's routines as a sub-Bot (job:<routine id>, shown as 루틴 · <name>). Seen by the company (visibility: "company").
  • A task opened (?task= in the address): its description, who has it (changeable), its notes, a box for a note, and — when this VM's 라피 worked it — 라피가 한 일 보기, the conversation feat(hub): 라피 takes the board's tasks given to it, works them as a routine runs, and hands them back #260 names task:<task id>. A 라피's note is never shown as a code (taskNoteText): laf:task_ran … steps:4 → 라피가 4단계로 마쳤어요. 확인해 주세요. · laf:task_needs_approval … topic:browser/activate@smartstore.naver.com → 라피가 허락을 기다려요: smartstore.naver.com에서 버튼 누르기 · needs_login → 저장된 로그인이 없어서 라피가 멈췄어요. · needs_person → 사람이 봐야 해서 라피가 멈췄어요. · run_failed → 라피가 끝내지 못했어요. · run_stopped → 라피의 작업을 멈췄어요. · a code this build does not know → 라피가 남긴 메모. A stop is drawn on the accent background. A person's note is its own text.

The doors (server/src/hub/routes-ui-tasks.ts, its own file beside routes-ui.ts, mounted at /api/hub, each behind requireUser):

App Hub (/hub/v1)
GET /api/hub/tasks?stage=&cursor=&limit= GET /tasks
GET /api/hub/tasks/:id GET /tasks/:id
POST /api/hub/tasks POST /tasks
PATCH /api/hub/tasks/:id PATCH /tasks/:id
POST /api/hub/tasks/:id/notes POST /tasks/:id/notes

Every call goes as x-laf-origin: person; every write as as: "member" with no run id, whatever the body says — as: "bot", a run id and checkout/heartbeat/release are a 라피's run's, and none is a door here. Only the board's fields are forwarded, each checked (a stage the board has, an assignee spelled m:|bot:|job:<id>, ids that stay in their path, numbers that are numbers); a PATCH without a version is refused unsent. Reads are asked once, writes keep the client's retries under the press's key. A 401 or 5xx from the hub is a 502 with its fact; 400/403/404/409/413/507 keep their status and laf: code (so laf:task_stale reaches the board as a 409). Same rules as #256's doors, spelled the same way in this file so the board and the other parts change without touching each other.

The room's working line (components/company/room-working-line.tsx, lib/hub/room-working.ts). Under an open room's messages: 이수진의 라피가 알아보는 중 (on the asker's own screen 내 라피가 알아보는 중) for each member who asked their own 라피 and has no answer from it after the question. And the room's box now sends mentions: ["lafi"] when the text says @라피, which is what #259 answers — #256's box sent no mentions, so nothing could ask.

The rule I chose for a failed or stopped turn. #259 gives the room no signal when the asker's turn fails or is stopped (it tells only the asker, in their own conversation), and its window is ten minutes — the line would hang that long over a question nobody is working on. So: on every screen the line shows for at most two minutes after the question (ROOM_WORKING_SHOWN_MS); the 라피's answer ends it sooner. The asker's own screen knows more: their VM's working list says whether a room run (origin: "room") is going, and while one is, their line stays up to #259's ten minutes; once none is and two minutes have passed, it goes like everybody else's. The line ends at the moment its window passes (a timer to the soonest end), not on the next minute. The asker also gets 내 라피 대화에서 보기, linking to their conversation for the room (room_<room id>_<bot id>, #259) once it exists. Trade-off, said plainly: a colleague watching a long answer (over two minutes) sees the line go before the answer arrives.

The rule is copied, not imported. #259's lafiWorking lives in shared/hub/room-answers.ts on its branch; merging #259 here would bring #255's tools and #259's engine changes into this PR. The rule (about 30 lines) is copied into lib/hub/room-working.ts with its own window and the asker's extension; at merge the two can become one (see below).

Left out: the board reads up to five pages of 200 tasks; no filters (mine / a person's); no title or description editing after creation (the door forwards both, the screen does not offer it yet); visibility other than company is not offered when creating; nothing works a job: task yet (no VM side picks one up — #260 takes bot:<me> only), so giving one to a routine only records it; a rank that runs out of float precision after ~50 halvings at one spot is not renumbered.

For the merge

Where it runs

  • New state that outlives a request? None on the VM. The board is TanStack Query's cache in the browser; the tasks are the hub's (feat(hub): the task board — tasks, notes, a run's claim on one that only one run wins, and the sweep that frees it #253).
  • New listener, port, or schedule? None. Five routes on the existing API; nothing polls (the working line reads the working list the app already polls, and one timer to the soonest line's end).
  • Anything the deployment cannot honour? done by a 라피 is refused by the hub; a person may set 끝, and laf:task_done_by_person has its words if it ever reaches the board. 업무판 is under 회사, which is drawn only where there is a company.

Boundary and audit

  • Every acting call still goes through the gateway — nothing here acts for a Bot; no tool, prompt or tool result changed.
  • New refusals and new failures each write a row — the hub writes its audit rows for task writes (x-laf-origin: person); this door refuses only malformed input (400 laf:request_invalid), which acts on nothing.
  • Nothing new is trusted from the client that the server can resolve itself: who the member is comes from the pass; as is forced to member and runId dropped; ids, stages, assignees and query fields are checked and spelled again.
  • Nothing new lets an action past a person.
  • A Bot cannot write the rule.
  • Nothing new records a value somebody typed. Titles and notes go to the hub and nowhere else on the VM.

Korean

  • Every new string goes through t() with its Korean entry in this change (next to the 회사 block, 49 entries).
  • No string is read through a variable; the note sentences and stage names are literal t() in switches, and hub-tasks.test.ts checks each note's key has its Korean.
  • The server sends facts only; a 라피's note is a code the surface turns into words.

The gate

On this branch's head, LAF_TEST_DB_SUFFIX=w1 LAF_TEST_WORKERS=2: head bce4cb5c, 2026-10-17 16:16 KST: typecheck exit 0; biome lint no errors (the 2 warnings and 1 info are in files this PR does not touch); format clean; test:ci exit 0 — server 4830 / floor 4789, app 2825 / 2608, agent-computer 651 / 632, root 1780 / 1744, hub 48 / 48.

  • All four pass. Floors (own commit): server 4769 → 4789 (+20, hub-routes-ui-tasks.test.ts), app 2564 → 2608 (+44: hub-tasks.test.ts 34, hub-room-working.test.ts 10). The three files are in scripts/test-manifest.json.
  • Every test a change adds was seen failing with what it guards reverted (below).

Proof

Live. A scratch hub built from this branch with #253 (laf/hub-tasks) and #250 (laf/hub-rooms) merged in (the HUB_EVENT_TYPES lists unioned, rooms' migration renumbered 0002), on :7400 with its own database; workspace 라프상사 and two members registered through the signed POST /hub/v1/fleet (A 김민수 admin, B 이수진 member). Two product servers of this branch, each on its own database and pass (A :3250, B :3251, LAF_DEV_NO_AUTH), each with a Vite dev server of this branch (5250 → A, 5251 → B), headless Chrome (Playwright channel: "chrome"), ko-KR, 1280×820. Before each run the board was put back to a known state through A's PATCH door. The needs-approval note was written to the hub with A's pass as as: "bot" — the line #260 writes (#260 is not in this stack), and the conversation link of a worked task could not be shown live for the same reason (taskConversationOf is tested).

  1. A drags, B sees it. Five mouse drags on A's page (3월 매출 정리 → 하는 중, 거래처 견적서 보내기 → 하는 중, → 검토, → 할 일, → 하는 중); B's page, never reloaded, waits for the card in that column: 31, 77, 75, 27, 24 ms after A's release (each includes A's PATCH through A's server to the hub, the hub's event over B's server's line to B's socket, and B asking B's server, which asks the hub). Screenshots: A after the drags, B showing the move.
  2. Reorder inside a column. 재고 확인 dragged above the first card of 할 일: A 재고 확인 / 매장 휴무 안내문 만들기 / 거래처 견적서 보내기, B the same in 27 ms, and the same after A reloads (the rank was kept by the hub).
  3. Escape mid-drag: the card stays where it was. A click opens, a drag does not: a press on a card opens it (?task=t_…); after a drag no dialog; the next press opens it again.
  4. Keyboard: focus on a card's menu, Enter, 검토, Enter → the card is in 검토 on B's page; A's status line says 검토(으)로 옮겼어요. Screenshot of the menu.
  5. A stale move gets the notice. A third page (B2, B's server) with its socket cut (routeWebSocket), so it hears no frames and keeps the old version; A moves 3월 매출 정리 to 검토; B2 drags it to 막힘 → the hub answers 409 laf:task_stale, B2 says 방금 다른 사람이 이 업무를 바꿨어요. 업무판을 새로 불러왔어요., and its board, read again, shows the card in 검토 (A's move), not 막힘. Screenshot.
  6. The needs-approval note in Korean. B opens 스마트스토어 가격 수정 (김민수의 라피, 막힘): notes read 김민수의 라피 — 라피가 허락을 기다려요: smartstore.naver.com에서 버튼 누르기 and 김민수의 라피 — 라피가 4단계로 마쳤어요. 확인해 주세요. — no code on screen. Screenshot.
  7. New task, to 내 라피. B: 새 업무, a title, 누가 하나요 → 내 라피, 추가: B's card says 내 라피, A's 이수진의 라피, on A's page in 76 (93 in a second run) ms. Screenshot of the choices (아직 안 정함 · 내 라피 · 팀원: 나, 김민수).
  8. A finger. A page with touch (hasTouch), touch events through CDP (Input.dispatchTouchEvent, pointerType touch) on the grip of 재고 확인 → 막힘: A's page shows it there. (The first cut cancelled a finger's drag: the grip's icon holds a touch's implicit capture, and handing it to the card raised lostpointercapture on the icon, which bubbled to the card; now only the card's own capture ending cancels.)
  9. The working line. B writes @라피 이번 주 매장 일정 정리해 줘 in the room 매장 운영: A's page (fake clock installed) shows 이수진의 라피가 알아보는 중 in 30 ms; B's shows 내 라피가 알아보는 중. At 1:50 after the question A still shows it; at 2:05 it is gone (no answer came: feat(hub): "@라피" in a team room is answered by the asker's own VM, in a conversation for that room #259 is not in this stack, which is the failed-turn case). Screenshots.
  10. No page error in any run. Console errors: GET /favicon.ico 404 from the Vite dev server (not this change), and B2's own 409 in step 5 (the stale move, expected).

Each test seen failing with what it guards reverted:

  • body: {...body, as: body.as ?? "member"} forwarded on POST /tasks → 5 of hub-routes-ui-tasks.test.ts fail.
  • case 409 removed from statusFor → "a stale move keeps the hub's 409" fails.
  • the version check on PATCH removed → "a move with no version is refused unsent" fails.
  • rankBetween answering before instead of the midpoint → 2 fail; the "already there" check in placeTask removed → 2 fail.
  • the laf:task_needs_approval case renamed → 2 fail; laf:task_run_failed → 1 fails.
  • the hub.task.changed case in hubKeysForFrame renamed → 1 fails.
  • the two-minute window replaced by ten for everybody → 2 of hub-room-working.test.ts fail; the "answered after" check removed → 2 fail.

Model calls: no chat turns. Each server's boot made its two usual warm-up calls (4 calls, 1,165 + 732 input tokens each) — well under $0.01.

🤖 Generated with Claude Code

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

Review of PR #268 — company hub, task board, and room working line. The diff adds a full company hub service (hub/), company screens in the app, a draggable task board, and a room "알아보는 중" line. I read the visible server-side hub code (auth, events, idempotency, socket, routes, cursor, runtime), the app-side room-working logic, the new-task form, the task card, and the channel event wiring. The server-side hub code is careful and consistent. The app-side code has one real bug and one minor design note.

✅ No issues worth flagging.

⚠️ The diff was large; some files were excluded from the review.


solar-pro4 (openrouter) · 82 files · +16564/-10 · 220.6s · CodeSolar

Comment /codesolar on this PR to re-run the review.

yulmucoffee and others added 3 commits October 18, 2026 11:26
…m's "알아보는 중"

회사 gets a 업무판 part (`/company/tasks`): the tasks this person can see in five columns —
할 일 · 하는 중 · 검토 · 막힘 · 끝 — moved between and within columns by dragging (pointer
events, our own code), or from each card's menu for the keyboard; a new task given to nobody, to
내 라피 (`bot:<me>`), to a teammate or to one of this person's routines (`job:<id>`); a task
opened shows its notes, a 라피's coded notes (#260) in plain Korean, and the conversation it was
worked in when this VM's 라피 worked it.

A move carries the version the board read and the rank halfway between its new neighbours, shows
at once, and is the hub's to keep: 409 `laf:task_stale` reads the board again and says so; a
colleague's move arrives as a `hub.task.changed` frame.

The server's doors are their own file (`server/src/hub/routes-ui-tasks.ts`): every call as the
person, `as: "member"` and no run id whatever the body says, only the board's fields, a key on
every write.

In a team room, "@라피" now asks the writer's own 라피 (`mentions: ["lafi"]`), and every member
sees "○○의 라피가 알아보는 중" until that 라피 answers — for at most two minutes, since a turn
that failed posts nothing to the room; the asker's own screen keeps it while their VM's room run
is going, and links to their conversation for the room.

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
… 2608)

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
@yulmucoffee
yulmucoffee changed the base branch from main to laf/hub-ui October 18, 2026 02:28
@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review 🟡 Minor notes

🔄 2nd review of this pull request · 29 new commit(s) since the last one

Review of PR #268 — 업무판 (company task board) with drag-and-drop, keyboard moves, live updates, and room working line. Found one correctness issue in approvalTopicText and one potential race in drag click-swallowing.

✅ No issues worth flagging.


solar-pro4 (openrouter) · 26 files · +2944/-33 · 185.1s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread app/src/components/tasks/task-board.tsx
yulmucoffee and others added 2 commits October 18, 2026 11:52
…ove is on its way

A card whose move was still unanswered was held back only from a drag (`canDrag`). Its menu still
called `moveTo`, which read the optimistic card and sent a second PATCH with the version the first
was about to replace; the hub refused it as `laf:task_stale`, the second place was dropped and the
board said somebody else had changed the task.

`moveTo` now keeps the cards whose move is on its way in a ref, set the moment the move is sent and
cleared when it settles, and sends nothing for one of them — whichever way the move came. The
card's menu draws its moves disabled while the card is pending.

hub-task-board-pending.test.tsx mounts the board, holds the first PATCH open, presses the menu
twice and counts one PATCH; once the hub answers, the next move goes with version 2. With both
changes reverted (`git checkout` of task-board.tsx and task-card.tsx) it fails: two PATCHes. Either
change alone makes it pass.

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 72291bc. Configure here.

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

🔄 3rd review of this pull request · 2 new commit(s) since the last one

Reviewing PR #268 — 업무판 (company task board) with drag-and-drop, keyboard moves, room working line, and task detail. The PR adds ~3000 lines across app and server, with corresponding tests. Previous review at 47cfc03 was clean; 2 new commits since.

✅ No issues worth flagging.


solar-pro4 (openrouter) · 27 files · +3088/-33 · 254.8s · CodeSolar

Comment /codesolar on this PR to re-run the review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant