Commit Graph

472 Commits (756023e30450ab99cdf9544850cd7027613f18ac)

Author SHA1 Message Date
ahmed.mujtaba c5d603c621 approved permission 2026-09-04 17:58:19 +05:00
ahmed.mujtaba 0365ee3a8f Merge pull request 'permission module base applied' (#69) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 37s Details
Reviewed-on: #69
2026-09-04 11:49:42 +00:00
ahmed.mujtaba 3cdfdd4c4c Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into SQS_BROKER 2026-09-04 16:49:14 +05:00
ahmed.mujtaba 4086bbdeb4 permission module base applied 2026-09-04 16:48:30 +05:00
ahmed.mujtaba 6a1f5ba9fa Merge pull request 'SQS_BROKER' (#68) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 33s Details
Reviewed-on: #68
2026-09-04 11:12:47 +00:00
ahmed.mujtaba c353f41b20 Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into SQS_BROKER 2026-09-04 16:11:53 +05:00
ahmed.mujtaba 0558af52c4 . 2026-09-04 16:10:56 +05:00
Talha Ahmed 8dcd262dc4 Repair CVs that pypdf extracts one character per line
CI / checks (push) Successful in 2m46s Details
Reported as "LinkedIn is not showing though the resume has it". The LinkedIn
was never the problem.

Traced on the live application (Mohammad Raza, inbox row 2f81cebc). Its stored
resume_text is 4,555 characters over 2,278 lines, and every one of those lines
is exactly one character long. The CV really does say

    LinkedIn:linkedin.com/in/mohammad-raza-digital-marketer

but it is stored as forty separate lines, so nothing that looks for a substring
can find it. Not a link annotation, not an image, not OCR: pypdf's default mode
breaks after every glyph on PDFs whose author positioned each one separately,
which design tools do routinely.

It survived review because a model reads that text fine. The candidate was
classified, matched and scored normally. What fails, silently, is every check
that asks "does this string appear in the resume":

  - slugs_from_text finds no profile, so linkedin_slug is stored empty
  - _clean_skills drops every skill, since each must appear in the text
  - the company and education clamps drop theirs for the same reason
  - verify_matched_keywords drops every matched keyword in the ATS engine

despace_line could not help: it rebuilds glyphs padded *within* a line, and
here there is nothing left on a line to rebuild.

is_glyph_fragmented measures the giveaway — the share of non-empty lines that
are a single character — and extract_pdf_text re-extracts with pypdf's layout
mode when it trips. Layout mode is the fallback, never the default: it is
slower and pads ordinary documents with alignment whitespace, so a CV that
extracts cleanly today is untouched. The fallback is checked before it is
trusted; fragmented text still scores a candidate, empty text fails them.

Both extractors had the defect, so the helpers live in app/services/pdf.py,
which owns PDF handling and is already imported by the recruiting path.

Measured against that real CV, before and after:

    slugs_from_text        []            -> ['mohammad-raza-digital-marketer']
    profile_url_from_text  None          -> https://www.linkedin.com/in/...
    lines                  2278          -> 61
    single-char lines      2278          -> 0
    'performance' found    False         -> True
    'google ads' found     False         -> True

Existing rows keep their broken text; extraction runs at ingest. Re-running
the match on affected rows is what backfills them.

.gitignore had `tests/**` twice and `/backend/tests/**` once. Both suites are
tracked and both run in CI, so the rules were inert for existing files and did
nothing but swallow new ones — this test was invisible to `git status` until
they went. That is also why they are removed rather than negated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:29:05 +05:00
ahmed.mujtaba d541b82067 Merge pull request 'deparmetn req1 maanged' (#67) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 35s Details
Reviewed-on: #67
2026-09-04 08:08:28 +00:00
ahmed.mujtaba 6ebdd7c5ba deparmetn req1 maanged 2026-09-04 13:07:18 +05:00
Talha Ahmed 0d209315ab Candidates: show ATS score, stage and recruiter instead of just an email column
Deploy to S3 / deploy (push) Successful in 34s Details
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.

Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.

Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:59:04 +05:00
Talha Ahmed 0eff43def4 Candidates: show ATS score, stage and recruiter instead of just an email column
CI / checks (push) Successful in 2m40s Details
The recruiter table was user-centric (GET /candidate/fetch/users), so it could
only ever render account fields - name, email, created date. Everything a
recruiter actually triages on lives on the application, not the user.

Point the table at GET /candidate/fetch and map application rows through a new
toApplicationListView, adding Job, ATS (score + band chip), Stage and Recruiter
columns. Stage and band become real filters; the dead Department facet is gone.

Manual uploads came back unscored because the list path never joined the ATS
results, so attach scores there and expose ai_score/recommendation from the
manager serializer, deriving the band from the score when the model omitted it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:45:35 +05:00
Talha Ahmed 8540577531 Fix inbox loading stall
Deploy to S3 / deploy (push) Successful in 34s Details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 19:59:26 +05:00
Talha Ahmed 22b2a7323f Fix inbox loading stall
CI / checks (push) Successful in 2m31s Details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 19:53:23 +05:00
ahmed.mujtaba 2101fbac74 Merge pull request 'remove the ui bnutton' (#66) from Implement_Changes into main
Deploy to S3 / deploy (push) Successful in 39s Details
Reviewed-on: #66
2026-09-03 14:01:41 +00:00
ahmed.mujtaba 3ca8e2d287 Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Implement_Changes 2026-09-03 19:00:44 +05:00
ahmed.mujtaba cb94d8bc51 remove the ui bnutton 2026-09-03 18:59:47 +05:00
Talha Ahmed 0113509fa1 Deploy: stop shipping 90 MB of node_modules the build throws away
CI / checks (push) Successful in 2m46s Details
The S3 artifact is 32.9 MiB compressed. Every other project's zip in that
bucket is under 1 MiB. The difference is frontend/node_modules, which is
committed to this repo and so was swept into every upload.

Earlier I left it in because nothing in the repo says what consumes the
bucket object, and if that side ran the app without installing dependencies,
dropping it would have broken the deploy. That is now answered rather than
assumed. Traced on the instance:

  CodeDeploy extracts to /opt/codedeploy-extracted-5, the AfterInstall hook
  copies the tree to /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group,
  then runs `docker compose --env-file ./backend/.env up -d --build`.

Eleven containers come up and the only Node one is hrms-frontend, built from
frontend/Dockerfile, which does `npm ci` against the lockfile. On top of that
frontend/.dockerignore excludes node_modules/ from the build context outright,
so even the copy that arrived could not have been read. It was carried across
the wire on every merge to main and then discarded unread.

The exclusion patterns were verified rather than trusted: zip -r with
-x on a synthetic tree in a temp dir on the box, since zip is not available
locally. That run also confirmed the previous commit's other fix — the
original `-x ".gitignore/*"` really did fail to match the file, and
`-x ".gitignore"` matches it.

Not done here, deliberately: node_modules is still tracked in git, which is
why it was in the artifact in the first place. Untracking it deletes 5,230
files from every other contributor's working tree on their next pull, across
thirteen active branches, and needs a heads-up rather than a surprise.

Two things found while reading the deploy script, neither touched:

  - It copies with `cp -r` and never deletes, so a file removed from the repo
    survives on the server indefinitely. Switching to a delete-on-sync would
    risk backend/.env, which the script deliberately preserves.
  - It re-downloads the latest docker compose and buildx from GitHub on every
    single deploy, unpinned, as root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:39:43 +05:00
Talha Ahmed 3b5ba425e8 CI: verify the build before it ships, and fix two dead lines in the deploy
CI / checks (push) Successful in 3m36s Details
The pipeline deployed whatever was on main without checking any of it. No
build, no tests, no lint. A frontend that failed to compile zipped and
uploaded exactly like a working one, and the first sign of trouble would have
been the running site.

The checks now gate the deploy. `deploy` needs a `checks` job, so a red main
never reaches the bucket. A second workflow runs the same script on branches
and pull requests, so the answer arrives before the merge rather than after
it. main is excluded there because the deploy workflow already covers it.

Both call scripts/ci-checks.sh rather than inlining the commands twice, which
is the only way the two cannot drift into disagreeing about what passing means.

What gates, and what deliberately does not:

  - frontend: npm ci then npm run verify. All four suites are jsdom, so no
    browser is needed on the runner. Node 22 matches frontend/Dockerfile.
  - bulk-ats: ruff check, ruff format --check, mypy, pytest. Scoped to app and
    tests. Repo-wide, ruff reports 926 errors and would reformat 126 files —
    the backend is written to another style and demanding a rewrite of it is
    not this change's business. The scoped set is clean today.
  - backend/tests: passes and gates, minus test_candidate_forms.py and
    test_employment_agent.py. Those nine failures predate this change and are
    unrelated to it. They are named in the script, not silently skipped, so
    the exclusion stays visible and someone can delete the two lines.

Two dead lines in the deploy, found while reading it:

  - The step named "Configure AWS credentials" set three variables and then
    only echoed a message. `env:` is scoped to its own step, so the values were
    discarded before anything could use them. It did nothing while reading as
    though credentials were configured globally. Removed; the upload step sets
    them where they are actually used.
  - `-x ".gitignore/*"` excludes a *directory* named .gitignore, which does not
    exist, so the file was never excluded. Now `-x ".gitignore"`.

The zip still ships frontend/node_modules, about 90 MB and most of the
artifact. Left alone on purpose: nothing in this repo says what unpacks the
object — there is no appspec and no deploy script here — so if that side runs
without installing dependencies, dropping it would break the deploy. The
workflow now carries a comment saying so and the one-line change to make once
that is confirmed.

.gitattributes pins *.sh to LF. The script is bash on a Linux runner; committed
with CRLF from a machine with core.autocrlf=false it would fail on line one
with `$'\r': command not found`, which reads as a broken pipeline rather than a
line-ending problem.

Verified by running scripts/ci-checks.sh locally end to end, exit 0. Only the
npm ci line was skipped, because it would rewrite the 5,230 committed
node_modules files; frontend/Dockerfile already builds that way. Both workflow
files were parsed and their job graph inspected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:22:59 +05:00
ahmed.mujtaba d1455ed117 Merge pull request 'Implement_Changes' (#65) from Implement_Changes into main
Deploy to S3 / deploy (push) Successful in 36s Details
Reviewed-on: #65
2026-09-03 13:18:40 +00:00
ahmed.mujtaba a6d12be5fb Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Implement_Changes 2026-09-03 18:17:51 +05:00
ahmed.mujtaba 6c1d4c848e dater format done 2026-09-03 18:16:30 +05:00
Talha Ahmed 921d3bf383 Inbox: label CVs the pipeline could not read, on the row itself
A CV goes through four steps before it is usable: the intake classifier
decides it is an application, the PDF text is extracted, an agent matches it
against job posts, and only then is an ATS score written. A PDF with no
readable text stops at step two. It is never matched and never scored.

The list said nothing about any of this. The parse badge existed only in the
detail pane, so a dead row looked exactly like a good one until you clicked
it. Across eleven hundred applications that is not discoverable, and the
candidate whose file happened not to open is simply lost.

The row now carries the badge, with two deliberate limits:

  - Exceptions only. Parsing, Failed and Pending show; Parsed shows nothing.
    A green chip on the overwhelming majority of rows is decoration that
    buries the two states worth spotting, and no badge already reads as fine.
    The detail pane still shows every state, Parsed included, because there
    the row is the whole subject.
  - Email only. Sheet Form applicants have no mailbox attachment to parse.
    The guard is redundant against today's mapFormRow, which sets no
    resumeStatus at all, and is kept as a tripwire rather than a load-bearing
    check — the comment says so rather than overclaiming.

"Failed" on its own tells a recruiter nothing to do next, so each state got a
plain-language tooltip saying what happened and that the attachment is still
there to open by hand. That needed Badge to forward `title`, which it did not.

The colour rule was duplicated inline in the detail pane; it is now one
helper both halves call, so the same state cannot paint two colours.

Four assertions added to inbox-loading.test.mjs. Three are proven sensitive
by reverting the change: removing the badge fails the label and tooltip
checks, and badging every state fails the exceptions-only check. The fourth,
that form rows stay unlabelled, passes even without the guard and is marked
in the test as a tripwire, not proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:48:28 +05:00
Talha Ahmed db0652ab24 Inbox: serve the queue from cache instead of re-skeletoning every visit
Opening the Recruitment Inbox showed skeleton placeholders every time, and
on the combined All channel it showed them stacked ABOVE rows that had
already arrived — the channel ORs its two sources' pending flags together,
so the email rows sat under placeholders while the sheet fetch finished.

The queue is a local table, not a live feed. Rows only appear when the Sync
worker writes them, and that path already invalidates qk.mailbox.all(). So
refetching on every visit bought nothing and cost a full-width skeleton each
time — worse on the All channel, which fetches BOTH sources unpaged and so
re-downloaded thousands of rows to render ten.

  - LIST_CACHE / COUNT_CACHE: 10 min staleTime, 1 h gcTime, keepPreviousData.
    A revisit inside the window paints rows immediately and issues no request.
  - Placeholders now mean "nothing to show yet": gated on the list being
    empty. A refetch over live rows is a 2px bar and the word "Updating…",
    with the rows readable and clickable throughout.
  - "Updated 4 min ago" + a Refresh button under the search box, because a
    cached list that never admits its age reads as a broken list. Refresh
    re-reads the DB; Sync pulls new mail from Outlook. Both tooltips now say
    which is which — two circular arrows were indistinguishable.
  - Search debounced to 300ms. Each keystroke used to mint a query key, a
    request and a skeleton, so the list strobed while you typed.
  - resume_text dropped from the list row mapping. serialize_application
    ships the whole extracted CV on every row and the queue renders none of
    it; caching a thousand of them for an hour is not a trade worth making.
    The detail query fetches it for the one row actually open.

Trade-off: a sync run started elsewhere is now invisible here for up to ten
minutes. The age label and the Refresh button are the deliberate mitigation.

inbox-loading.test.mjs pins all of it, driving hand-gated fetches so the
frames a fast API flashes past become assertable: both sources pending, one
source home, and a second visit served from cache. Reverting the placeholder
rule reproduces the original defect as "skeleton=true rows=2". Wired into
npm run verify; needed a mountRoute export on the smoke harness, since
renderRoute tears the tree down before those intermediate frames can be read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 16:39:55 +05:00
ahmed.mujtaba 3f142570f6 Merge pull request 'Implement_Changes' (#64) from Implement_Changes into main
Deploy to S3 / deploy (push) Successful in 37s Details
Reviewed-on: #64
2026-09-03 11:27:42 +00:00
ahmed.mujtaba fb94db5f41 Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Implement_Changes 2026-09-03 16:25:22 +05:00
ahmed.mujtaba e22da6ed0b . 2026-09-03 16:13:09 +05:00
ahmed.mujtaba 1133517117 . 2026-09-03 16:03:42 +05:00
ahmed.mujtaba 1399cf3fec Cv_extractor of g_sheet 2026-09-03 15:15:16 +05:00
talha.ahmed 2400f0fef8 Merge pull request 'Roles: consolidate the hand-made Manager role; Title Case role names' (#63) from Talha into main
Deploy to S3 / deploy (push) Successful in 31s Details
2026-09-02 17:11:11 +00:00
Talha Ahmed e7752b94e6 Roles: consolidate the hand-made Manager role; Title Case role names
Migration 027 moves live members of the UI-created Manager role onto
the seeded hiring_manager (which has carried the same
manager_candidates bundle since 024) and soft-deletes it - guarded and
idempotent, the seeded role is never matched.

Access Control no longer lists candidate: it is the applicant account
type every candidate user sits on, managed nowhere near a permission
matrix. The row stays in the DB.

New lib/format.js formatRole renders snake_case role names and
ALL-CAPS status enums as Title Case everywhere users see them -
Access Control, Settings user badges and role picker, the topbar
profile, Candidates role column and export, and the inbox
application-status badges (INTERVIEW -> Interview).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 22:10:17 +05:00
talha.ahmed e292714b88 Merge pull request 'Roles: prune unused seeded staff roles down to the org�s four' (#62) from Talha into main
Deploy to S3 / deploy (push) Successful in 35s Details
2026-09-02 17:01:10 +00:00
Talha Ahmed b7700b766a Roles: prune unused seeded staff roles down to the org's four
The organisation runs four staff roles - system_administrator,
recruiter, hiring_manager, department_head. Migration 026 soft-deletes
hr_administrator, interviewer and ceo (listings filter on is_deleted,
so they vanish from Access Control and every role picker). candidate
stays: it is the applicant account type every candidate user sits on,
not a staff role. Guarded: a role that still has live members is left
untouched until they are reassigned, and the idempotent migration picks
it up on a later boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 21:59:08 +05:00
Talha Ahmed cc9d7c08d1 Brand-styled XLSX exports across Inbox, Candidates and Talent Pool
New shared lib/exportXlsx.js: every Export button now downloads a
dashboard-flavoured .xlsx - deep-green title band with lime text, mint
meta row (view, row count, date), ink-teal header row, zebra data rows,
frozen header and autofilter. exceljs is imported dynamically so its
~1MB chunk only downloads when an export is clicked.

- Inbox: exports the loaded view (DB-filtered; no extra request)
- Candidates: the Export button was a stub that only fired a toast -
  it now really exports the filtered account list
- Talent Pool: upgraded from plain CSV to the same styled workbook

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 20:40:43 +05:00
talha.ahmed 30123563e9 Merge pull request 'Brand-styled XLSX exports (Inbox, Candidates, Talent Pool)' (#61) from Talha into main
Deploy to S3 / deploy (push) Successful in 32s Details
2026-09-02 15:40:26 +00:00
talha.ahmed 15bdd5c08a Merge pull request 'Inbox: Export CSV button; graceful partial failure on the All channel' (#60) from Talha into main
Deploy to S3 / deploy (push) Successful in 37s Details
2026-09-02 15:25:01 +00:00
Talha Ahmed 56a05f840f Inbox: Export CSV button; graceful partial failure on the All channel
Export downloads the current view - channel, tab and search respected,
fetched unpaged - as UTF-8 CSV (BOM for Excel) with both sources'
columns: name, contact, position, channel, source, received, status,
city, notice period, ATS score, assigned job.

All channel resilience: when exactly one source fails, the healthy
list stays with a one-line notice naming the gap; the full error state
now renders only when there are no rows at all (cached rows beat a
scary banner over a working list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 20:20:36 +05:00
talha.ahmed 0be2ba035a Merge pull request 'Inbox: single page scroll on phones, detail takes over from the list' (#59) from Talha into main
Deploy to S3 / deploy (push) Successful in 41s Details
2026-09-02 15:12:48 +00:00
Talha Ahmed 2abc28fc14 Inbox: single page scroll on phones, detail takes over from the list
The stacked split nested two scroll wells (list and detail each carried
max-height + overflow-y auto) inside the page scroll - scrollbars within
scrollbars on small screens. At <=900px both panes now flow naturally in
the one page scroll, and selecting an application swaps the list out for
the detail pane with a Back to list button (standard master-detail
collapse); selection also scrolls to the top so the detail header is in
view. Desktop keeps the two-pane split unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 20:12:18 +05:00
Talha Ahmed fbfcbb4eb8 Inbox: redesign the form applicant detail pane
Standard card language instead of the email-head strips: Contact &
application, Profile and Matching roles are now proper cards with
card-heads. The roles panel collapses three overlapping controls into
one flow - a single Browse roles action, with Assign appearing only
once a selection exists (a permanently disabled primary button read as
broken UI). Sheet provenance moves off the floating header corner into
the Imported from ... row N caption, and the action bar gains a
divider (email pane too, for consistency).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 19:40:03 +05:00
talha.ahmed 0e3c5d5551 Merge pull request 'Inbox: redesign the form applicant detail pane' (#58) from Talha into main
Deploy to S3 / deploy (push) Successful in 39s Details
2026-09-02 14:39:44 +00:00
Talha Ahmed ef8d77ccfe Inbox: tidy the queue pagination
The stacked pager read as three loose rows with 'of N' printed twice.
Queue-scoped: drop the duplicate page-size total (the Showing line
already carries it), wrap page-controls with even gaps, and slim the
page buttons so all nine nav controls (4 chevrons + up to 5 numbers)
fit the ~350px split column on one row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 19:20:49 +05:00
talha.ahmed 25382cdce7 Merge pull request 'Inbox: tidy the queue pagination' (#57) from Talha into main
Deploy to S3 / deploy (push) Successful in 34s Details
2026-09-02 14:20:31 +00:00
Talha Ahmed 2e66c91d63 Inbox: drop the Google Sheets status chip too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 19:13:42 +05:00
talha.ahmed c08e28a18c Merge pull request 'Inbox: drop the Google Sheets status chip too' (#56) from Talha into main
Deploy to S3 / deploy (push) Successful in 32s Details
2026-09-02 14:13:23 +00:00
talha.ahmed 12dc77542d Merge pull request 'Inbox: drop the Microsoft Graph API status chip' (#55) from Talha into main
Deploy to S3 / deploy (push) Successful in 34s Details
2026-09-02 12:59:06 +00:00
Talha Ahmed 59afcadec9 Inbox: drop the Microsoft Graph API status chip from the header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 17:57:27 +05:00
talha.ahmed 08db8dda15 Merge pull request 'Inbox: All channel first and default' (#54) from Talha into main
Deploy to S3 / deploy (push) Successful in 33s Details
2026-09-02 12:52:15 +00:00
Talha Ahmed 2bfdbdee93 Inbox: All channel first and default
The combined stream leads the channel pills (All / Email / Sheet Forms)
and is the channel the inbox opens on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 17:52:03 +05:00
Talha Ahmed ec8a4af52d Inbox: All channel - Email and Sheet Forms merged in one queue
Third channel pill beside Email / Sheet Forms. Both sources are fetched
unpaged (their endpoints read a missing top/limit as no LIMIT), merged
newest-first, and paged client-side - per-source skip/top cannot compose
into a correct global page. Rows carry kind (email/form) so the detail
pane, mark-read (email rows only get checkboxes; sheet rows have no
mailbox read state) and the kind-aware state/duplicate mutations all
pick the right endpoint per row. Tabs collapse to the shared set (no
Unread), counts and totals sum both sources, and the sheet filter spans
every sheet tab on this channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 17:36:52 +05:00