Commit Graph
19 Commits
Author SHA1 Message Date
Devin FoleyandPaperclip faf72cb1d5 fix: preserve company context across browser hot reload (#14482)
## Thinking Path

> - Paperclip uses a shared company context for browser providers and
consumers.
> - Vite can load a new consumer module while an older provider is
mounted.
> - Recreating the context disconnects that consumer from the mounted
provider.
> - The consumer then reports a missing provider even though one is in
its React ancestry.
> - This change preserves the context object across development module
refreshes.
> - Bounded global error diagnostics distinguish development bundles and
otherwise context-free promise rejections.

## Linked Issues or Issue Description

**What happened?**
A refreshed company consumer can throw `useCompany must be used within a
CompanyProvider`. A real Vite and Chromium reproduction confirms that a
retained provider and a refreshed consumer can hold different context
objects. Global promise rejections also lack the bounded document state
already attached to React boundary errors.

**Expected behavior**
A refreshed consumer should read the mounted provider. Error reports
should identify the loaded bundle mode and bounded browser state while
preserving monitoring opt-in, sign-out, and privacy behavior.

**Steps to reproduce**
Run `pnpm test:e2e:browser-context`. The isolated Vite fixture renders
the real CompanyProvider, imports a new timestamped consumer module, and
renders that consumer below the retained provider. The test fails before
the context change and passes after it. The SDK regression invokes its
real unhandled-rejection handler with an undefined reason.

## What Changed

- Keep the React context object in Vite's per-module `hot.data`. Account
values stay in React.
- Add an isolated browser regression with mocked API responses and no
live instance, discovered by the existing Chrome CI shards.
- Add document-state diagnostics to global errors while preserving
earlier boundary snapshots.
- Tag events with development or production bundle mode and the type of
an unhandled rejected value.
- Document the new test command and diagnostic fields.

## Verification

- Company context, browser context, and Sentry suites: 59 passed.
- `pnpm test:e2e:browser-context`: passed in Chromium. The original
context code fails the reproduction.
- Real SDK tests preserve DSN/sign-out behavior and omit request
context, breadcrumbs, and private DOM data.
- `pnpm check:token-gates`: passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Local `pnpm test:run` exited in the general-server phase: 13,819
passed, 86 skipped, 21 failures in unchanged filesystem and host-port
suites. Cache permission and long-path failures also reproduce on the
unmodified base; seven other failures involve local runtime port
ownership. This is not a full local pass.
- Full Linux CI and review passed at a0b5270662 (53 successful checks;
two conditional checks skipped). Three jobs interrupted by a runner
shutdown passed on rerun. Greptile is 5/5 with no unresolved comments.
The real browser regression also passed in the normal Chrome CI shard
(3.2 seconds).
- Code-owner approval remains required because the dedicated test
command changes `package.json`.
- Scanned the diff and PR text for credentials and private data before
pushing.

## Risks

The context cache applies only to development hot reload. It retains the
context object, not account state. Production continues to create an
ordinary React context. The diagnostic hook adds only bounded state and
type values, preserves boundary snapshots, and returns the original
event if enrichment fails. It does not suppress errors or restore raw
breadcrumbs. The global rejection diagnostics do not identify the
promise's originating operation by themselves.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, code editing,
and command execution. The runtime does not expose a more specific model
revision or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites and
Chromium regression; full local limitations documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-28 16:15:14 -07:00
TonioandClaude Opus 5 327f59cac2 refactor(ui): key the company list by account (#11488)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Which companies a person belongs to is an authorization fact the
server owns, and the UI caches the answer for speed
> - It cached that answer under one `["companies"]` key with no account
attached, while `main.tsx` sets `staleTime: 30_000` for every query
> - So for thirty seconds after an account change, one person's list
answered questions asked about another, arriving with no loading state
and no error
> - Three separate consumers each grew their own defense against this,
and each was a place to forget one
> - This pull request keys the entry by account, so a list belonging to
someone else is not distrusted but unreachable
> - The benefit is that the protection stops depending on every future
consumer remembering to defend itself

## Linked Issues or Issue Description

No public issue exists. Refs #11380, #11382, #11417, #11430. The problem
follows.

**What happened?**

The company list lived in a single cache entry, `["companies"]`,
carrying no record of which account it was fetched for. Combined with
the app-wide 30s `staleTime`, any read within that window after an
account change returned the previous account's list — from cache, with
no request, no loading state and no error.

Every consumer that treats the list as an authorization fact had to know
this and defend itself:

- `InviteLanding` reads it to decide whether you already belong to the
inviting company (#11417).
- `OnboardingWizard` reads it to decide whether a saved draft belongs to
you (#11382, merged).
- `CompanyProvider` reads it to pick and persist your active company
(#11430).

All three defenses are correct. The problem is structural: the fourth
consumer has to invent a fourth one.

**Expected behavior**

A cached company list can only answer questions about the account it was
fetched for.

**Steps to reproduce**

1. On a self-hosted instance in `authenticated` mode, sign in as account
A, which belongs to company X.
2. Within thirty seconds, have account B become the session in that tab
— a second tab signing in, or A's session lapsing server-side.
3. Any consumer reading the company list receives A's list, and nothing
in the query result indicates it is not B's.

**Paperclip version or commit**

`master` at `ac91b7f3b`, which includes #11430.

## What Changed

- `ui/src/lib/queryKeys.ts` — `companies.list(userId)` replaces
`companies.all` as the list's entry. `companies.all` remains the prefix,
so it still matches for invalidation.
- `ui/src/api/companies-query.ts` — `companyListQueryOptions(userId)`
builds the keyed options; `useCompanyListQuery()` is the only observer
entry point and holds until the session settles, because the key cannot
be built before then; `fetchCompanyListForCurrentAccount(queryClient)`
covers imperative paths; `useAccountIdentity()` exposes the session
identity the key is built from.
- `ui/src/api/companies-query.ts` — the `/companies` detach moved into
the query function.
- `ui/src/context/CompanyContext.tsx` — drops the session-watching
refetch machinery the key now makes unnecessary (`removeQueries`, the
explicit replacement fetch, the awaiting gate). It still clears the live
selection on an account change, because that is component state and does
not change key with the query.
- `ui/src/pages/InviteLanding.tsx`,
`ui/src/components/OnboardingWizard.tsx` — read through the
account-aware API.
- Tests — the account-keyed guarantee, prefix invalidation still
reaching the list, the detach inside the query function, and updates
where suites seeded the old shared key.

### The existing defenses are deliberately left in place

The per-consumer gates in #11382, #11417 and #11430 are now belt and
braces. They are also what will catch this refactor if it is wrong
somewhere, so removing them in the same change that moves the foundation
would be the wrong order. Simplifying them is a follow-up, once this has
proven itself.

### Why `retry: 1` appears in CompanyProvider

An earlier measurement on #11430 found a retry on the replacement fetch
changed no outcome, because `removeQueries` made the observer rebind and
issue a second request for free. Keying by account removes that
mechanism and the free attempt with it. The retry now carries the
property the incidental refetch used to — a single blip during an
account change should not leave the customer with no companies until
they find "Try again". #11430's test for that property is unchanged and
still passes, which is how the gap was caught.

### A regression this went through, kept for the record

Gating the query on the session settling meant that while the account
was unknown the query was *disabled*, and a disabled query reports
`isLoading: false` with no data — which the provider defaults to an
empty list and reads as "asked, and owns nothing". That is the
destructive branch #11477 had just fixed, reached through a different
door: it would have cleared the customer's stored company on every cold
boot. #11477's test caught it during the rebase. `useCompanyListQuery`
now reports the wait for the account as part of the wait for the list.

### What this does not do

It does not scope the rest of the per-account cache. `["companies",
id]`, stats, and every other account-scoped entry still survive an
account change; that is the cache-lifetime work in #11380.

## Verification

- `pnpm vitest run` in `ui`: **4018 passed, 1 failed**.
- `pnpm tsc -b` in `ui`: clean.
- `companies-query.test.ts`: 6 passed. `CompanyContext.test.tsx`: 17
passed. `OnboardingWizard.test.tsx`: 13 passed.
`InviteLanding.test.tsx`: 13 passed.

The failure is the pre-existing timezone-dependent
`IssueProperties.test.tsx`, fixed by #11478.

Two behaviours are asserted rather than assumed, because the refactor is
only safe if they hold: that invalidating the `companies` prefix still
marks the account-keyed list stale (19 call sites depend on it), and
that the query function detaches the in-flight `/companies` request
before fetching.

**Not done:** no manual two-account run in a browser. The path needs two
accounts on an `authenticated` instance, which a local dev instance
cannot exercise.

## Risks

Moderate, and worth reading before approving.

**It touches `InviteLanding.tsx`, which #11417 also modifies**, so one
of the two will need a rebase — the conflict is mechanical (both change
how the same query is read).

This was #11481, which GitHub closed automatically when its base branch
(#11430's) was deleted on merge; reopening a pull request whose base
branch is gone is not permitted, so it continues here against `master`
with the same head and the same review already recorded on #11481.

**The list now waits for the session query.** The key cannot be built
before the account is known. In the app the session is already fetched
at boot by many components, so this is a dependency rather than an extra
request, but it does serialize: on a cold boot the list waits for the
session to land. Every test that renders a company-list consumer now
needs a session in the cache, which is why several suites gained a seed.

**A missing mock surfaces as a passing gate rather than an error.** The
detach inside the query function meant suites whose `companiesApi` mock
lacked `detachInflightList` had their query function throw, which read
as "decided" in the onboarding gate and mounted the wizard early. Fixed
in the affected suites; worth knowing as a failure mode.

## Model Used

Claude Opus 5 (`claude-opus-5`), through Claude Code. Extended thinking
enabled. Tool use enabled: file read and edit, shell for typecheck and
test runs.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:23:56 -07:00
TonioandClaude Opus 5 ac91b7f3b2 fix(ui): scope the company selection to the signed-in account (#11430)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every company-scoped screen reads the active company from
`CompanyProvider`, which picks one from the `["companies"]` list and
remembers it in localStorage
> - That cache entry is shared app-wide and carries no account identity,
so it survives a change of account in the tab
> - The provider therefore auto-selects from whatever list is cached,
which can belong to the account that just went away
> - This pull request makes the provider watch the account and refuse to
derive a selection from a list fetched for a different one
> - The benefit is that the app stops pointing at a company the
signed-in account may not be able to see

## Linked Issues or Issue Description

No public issue exists. Refs #11380, #11382, #11417. The problem
follows.

**What happened?**

`CompanyProvider` auto-selects a company from the shared `["companies"]`
cache entry and writes that id to `localStorage`. Nothing ties that
entry to an account. When the account changes in the tab, the previous
account's list is still served, so the provider can select — and persist
— a company belonging to the account that just went away. Company-scoped
screens then render against a company the current account may not be
able to see.

Signing in through `Auth.tsx` invalidates the entry, so the in-app
sign-in path is covered. Two paths are not: a session that lapses
server-side, and a second account signing in on another tab. The
sign-out sweep in #11380 does not cover them either, because neither
presses the sign-out button.

**Expected behavior**

The company selection is derived only from a company list fetched for
the account that is signed in now.

**Steps to reproduce**

1. On a self-hosted instance in `authenticated` mode, sign in as account
A, which belongs to company X.
2. In a second tab, sign in as account B, which does not belong to
company X.
3. Return to the first tab. The session query refetches and reports
account B, while the company list is still account A's.
4. The provider keeps company X selected and leaves its id in
`localStorage`.

**Paperclip version or commit**

`master` at `6542ad1f4`.

## What Changed

- `ui/src/context/CompanyContext.tsx` — the provider observes
`queryKeys.auth.session`. On a change of session user it clears the live
selection, removes the shared company list, and holds auto-select until
a list fetched for the new account lands. The stored id is left alone on
purpose: `resolveBootstrapCompanySelection` re-validates it, so an
account signing back in keeps its company while an unrelated account
cannot inherit it.
- `ui/src/context/CompanyContext.tsx` — an errored list is treated as
undecided rather than as "no companies". With `retry: false` a single
network blip sticks, and the empty-list branch read it as proof the
account owns nothing and cleared the stored selection.
- `ui/src/api/client.ts` — new `detachInflightGet(path)`. GET coalescing
keys on the request path alone, so a `/companies` request issued under
the previous session could be joined by the replacement fetch and answer
it with the previous account's companies. Detaching leaves that request
to settle for its own callers and makes the next call issue a fresh one.
- `ui/src/api/companies.ts` — `companiesApi.detachInflightList()` wraps
that for the list path.
- `ui/src/context/CompanyContext.tsx` — `companyListUnavailable`
separates "no usable list because a request failed" from "this account
owns nothing", and `retryCompanies` gives a recovery action that
fetches. Both are derived from the query rather than tracked beside it;
a second copy of "did the last attempt succeed" drifted out of step
during review, reporting a failure over a later empty list that was
simply the truth.
- `ui/src/components/SidebarCompanyMenu.tsx` — renders "Couldn't load
companies" and a Try again item in place of "No companies", which is a
claim about the account that a failed request cannot support. This is
the menu `Sidebar` mounts, so it is the only place a customer can act on
the failure.
- `ui/src/components/CompanySwitcher.tsx` — the same treatment. The
application does not render this component (its only mount is a
Storybook story), so it is kept in step rather than relied on.
- `ui/src/context/CompanyContext.test.tsx`,
`ui/src/components/SidebarCompanyMenu.test.tsx`,
`ui/src/api/client.test.ts` — coverage for the account switch, a
same-account re-observation not churning, the detached GET, the failed
replacement and its recovery, a single blip self-healing, unavailability
not outliving the failure, and the sidebar rendering the recovery action
for a failure but plain "No companies" for an account that owns nothing.

### No `retry` override on the replacement fetch

The obvious fix for a failed replacement is a retry, and it is not
load-bearing here. A transient failure already gets a second attempt:
the observer rebinds to a fresh query on the render those state updates
schedule, and issues its own request — measured as two attempts with or
without the option. Retries would only add failed round trips before a
real outage is reported, and the outage is what needs a way out, which
is what `companyListUnavailable` and `retryCompanies` provide.

### Why `removeQueries` here, and why that does not generalise

Removal notifies no observer. What rebinds them at this call site is the
render the surrounding state updates schedule; every observer re-binds
to a fresh query on the next render. A caller without that guarantee
would leave mounted observers serving the previous account's value, so
this is not a pattern to lift elsewhere — the sign-out sweep in #11380
must use `resetQueries` instead, and its measurements are at
[#11380](https://github.com/paperclipai/paperclip/pull/11380#issuecomment-5300984911).

The inverse caveat holds for a local reset under an observer that stays
mounted, which is why #11417 and #11382 avoid `resetQueries`.

## Verification

- `pnpm vitest run` in `ui`: **4014 passed, 1 failed**.
- `pnpm tsc -b` in `ui`: clean.
- `CompanyContext.test.tsx`: 16 passed. `SidebarCompanyMenu.test.tsx`:
15 passed. `client.test.ts`: 9 passed.

The failure is pre-existing and unrelated: `IssueProperties.test.tsx`
expects `4:08 PM` and gets `9:08 AM`, a timezone-dependent assertion. It
reproduces on a tree without this change, and #11478 fixes it.

Each new test was confirmed to fail against the implementation it
covers, by reverting that change and re-running rather than by assuming.
The account-switch test fails without the fix (the selection stays on
the previous account's company and no refetch is issued); the
flag-clearing test fails without its clause (an empty list keeps reading
as "couldn't load").

**Not done:** no manual two-account run in a browser. The path needs two
accounts on an `authenticated` instance, which a local dev instance
cannot exercise.

## Risks

Low. The failure direction is a company selection withheld for one extra
round trip, which resolves when the list arrives. The direction it
removes is one account's company selected and persisted for another.

It adds one company-list request per account change, on a query key the
app already uses. It adds no request at boot: the session query it
observes is already fetched app-wide.

**This does not close the class.** Company-scoped entries other than the
list — `["companies", id]`, stats, and the rest of the per-account cache
— still survive an account change. That is the cache-lifetime work in
#11380, not this provider's.

## Model Used

Claude Opus 5 (`claude-opus-5`), through Claude Code. Extended thinking
enabled. Tool use enabled: file read and edit, shell for typecheck and
test runs, and a scratch vitest harness to measure `removeQueries` and
`resetQueries` notification behaviour against the installed
`@tanstack/query-core` 5.101.4.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:51:03 -07:00
TonioandClaude Opus 5 e384d0a2bd fix(ui): keep the stored company when the company request fails (#11477)
An error is not an answer, and this branch is destructive.

`companiesListQueryOptions` sets `retry: false`, so a request that fails before
ever succeeding leaves `data` undefined - which `CompanyProvider` defaults to
`{ companies: [], unauthorized: false }`. That is indistinguishable from "this
account was asked, and owns nothing", so `shouldClearStoredCompanySelection`
returned true and the effect removed the customer's stored company. With
`refetchOnWindowFocus: true`, a blip on focus during a cold load was enough,
and the next visit drops them onto whichever company sorts first.

The predicate now takes `errored` and refuses to clear on it. Required rather
than optional, so the compiler made both existing call sites state their
answer instead of inheriting a default.

Not clearing costs nothing: a stored id that no longer resolves is ignored by
`resolveBootstrapCompanySelection`, which checks it against the current list
before using it. Clearing wrongly costs the customer's selection, which cannot
be recovered.

Scoped deliberately. This file had been described as carrying the same defect
as the onboarding draft gate and the sign-out sweep, and that was overstated.
Those two *trusted* a stale list to answer "does this account own this
company?". This one validates membership against the current list and only
picks a default, so a stale list here self-corrects rather than leaking. The
failed-request branch is the part that is genuinely wrong, and it is the only
part changed. The transient re-decision during a background refetch is real,
self-correcting, and left alone.

Tested at both levels, because the predicate alone would not have caught it:
the provider is what defaults a failed request to an empty list, so the wiring
is where the decision goes wrong. Removing the guard fails both.

ui typecheck clean; full ui suite 4016 pass, with only the timezone-dependent
IssueProperties failure already present on master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:58:46 -07:00
Devin Foley 61a5b7c6f9 fix(ui): stop the selection ping-pong on archived company URLs (#11300)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The UI keeps a selected company in `CompanyProvider` with two
writers: a bootstrap effect that repairs invalid selections, and a
Layout route-sync effect that selects the company the URL prefix names
> - The route-sync matches the URL against the full company list
(archived included), while the bootstrap resolver only accepted
companies from the sidebar-filtered non-archived list
> - On any archived company's URL the two effects overwrite each other's
selection in a synchronous loop until React throws error #185 ("Maximum
update depth exceeded") and unmounts the root to a blank page — armed by
remembered last-visited paths, back/forward navigation, or bookmarks, on
first load and client navigation alike
> - This pull request makes an already-selected company only need to
exist, keeping the sidebar filter for fresh-boot resolution where no
explicit selection exists
> - The benefit is that archived company URLs render instead of blanking
the entire app

## Linked Issues or Issue Description

No existing issue. Description follows the bug template:

**What happened?**

Opening (or back-navigating to) a URL whose company prefix belongs to an
archived company blanked the whole app with `Minified React error #185`.
Console in dev mode: "Maximum update depth exceeded. This can happen
when a component calls setState inside useEffect…". A workspace whose
first/seeded company was archived hit this on every load of its
remembered URL.

**Expected behavior**

An archived company's URL renders its pages (the company still exists
and its API routes serve data). The sidebar simply does not feature
archived companies, and fresh boots still land on a non-archived
company.

**Steps to reproduce**

1. Create two companies; archive one (`PATCH /api/companies/:id` with
`status: "archived"`).
2. Navigate to `/{archivedPrefix}/dashboard` — direct load or
client-side back-navigation.
3. Before this fix: React #185 and an unmounted blank page (reproduced
deterministically by the new e2e test).

## What Changed

- `ui/src/context/CompanyContext.tsx`:
`resolveBootstrapCompanySelection` keeps an explicitly selected company
that exists in the full company list; stored-id and default resolution
still prefer sidebar (non-archived) companies.
- `ui/src/context/CompanyContext.test.tsx`: resolver keeps an
archived-but-existing selection; a truly deleted selection is still
replaced.
- `tests/e2e/archived-company-url.spec.ts`: end-to-end regression
driving both field shapes (direct load and back-navigation onto an
archived company URL); it failed with the exact #185 console errors
before the fix and passes after.

## Verification

- `pnpm vitest run src/context …` in `ui/` — 122 tests pass (includes
the new resolver cases).
- `npx playwright test --config tests/e2e/playwright.config.ts
archived-company-url` — fails before the fix (captured "Maximum update
depth exceeded" console errors), passes after.
- `pnpm typecheck` in `ui/` — clean.

## Risks

Low risk. The only behavioral change is that a selection naming an
archived-but-existing company survives the bootstrap repair — previously
that state was unreachable without crashing. Boots with no valid
selection behave exactly as before (non-archived preferred), covered by
the existing and new resolver tests.

## Model Used

- Claude (Anthropic), Claude Fable 5 (`claude-fable-5`), via Claude Code
CLI with extended thinking and tool use (code search, edit, test
execution, Playwright-driven crash reproduction).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-12 15:40:33 -07:00
Pat NewellandClaude Opus 4.8 bc0a076e13 fix(ui): stop linking foreign tracker keys as Paperclip issues (#7511)
Fixes #7514 — the prefix-validation piece of the #5456 auto-linker
404-storm umbrella. The remaining pieces (404 retry guard, word-boundary
tightening, comment edit/soft-delete) stay open under #5456.

Part of #5456.
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents and humans cross-reference work in markdown — issue
descriptions, comments, documents — which the UI renders through a
shared `MarkdownBody`
> - That renderer auto-links any `IDENT-123`-shaped token to an internal
`/issues/IDENT-123` link
> - But foreign tracker keys share that exact shape: a Jira `TREE-604`
(or any external `ORG-123`) mentioned in prose becomes a link to a
Paperclip issue that does not exist — it 404s, and the renderer also
fires a wasted issue-fetch for the bogus identifier
> - The set of real issue prefixes is already in the browser: every
company carries an `issuePrefix`, exposed via `CompanyContext`
> - This PR gates bare-token auto-linking to known company prefixes,
leaving explicit `issue://` / `/issues/` references and real markdown
links untouched
> - The benefit is no dead internal links from foreign keys, with no new
query, cache, or server change — and zero regression when prefixes
aren't yet known

## What Changed

- **`ui/src/lib/issue-reference.ts`** — `parseIssueReferenceFromHref`
takes an optional `knownPrefixes` set and rejects a bare `IDENT-123`
token whose prefix isn't in it; threaded through
`remarkLinkIssueReferences(options)` → tree rewrite → text and
inline-code paths. An omitted/empty set keeps the legacy permissive
behavior. Explicit `issue://` scheme and `/issues/` path forms are never
gated.
- **`ui/src/context/CompanyContext.tsx`** — adds `useOptionalCompany()`,
a non-throwing variant of `useCompany()` (returns `null` outside a
provider).
- **`ui/src/components/MarkdownBody.tsx`** — reads company prefixes via
`useOptionalCompany()` and passes them to the linkifier. The
non-throwing read keeps `MarkdownBody` renderable in provider-less
surfaces (e.g. standalone/exported markdown).
- Tests extended in `issue-reference.test.ts` (gating + remark-plugin
cases) and `MarkdownBody.test.tsx` (gating, empty-companies permissive,
explicit-path bypass).

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/issue-reference.test.ts src/components/MarkdownBody.test.tsx` —
green (17 + 40 tests).
- Full UI suite: `pnpm --filter @paperclipai/ui exec vitest run` —
**1161 passed / 183 files**; pre-existing `MarkdownBody` link tests pass
unmodified (they hit the permissive `null`-context path), confirming no
regression.
- `pnpm --filter @paperclipai/ui run typecheck` — clean.
- _Screenshots pending — opening as draft; before/after images to follow
before marking ready._
- Manual (before/after): in an issue description containing both a real
Paperclip identifier and a Jira key — _before_ both render as
`/issues/...` links (the Jira one dead); _after_ only the real
identifier links and the Jira key is plain text.

## Risks

- **Low risk.** No server/API/migration change; pure client rendering
logic.
- A referenced issue whose company isn't in the viewer's `companies`
list stops auto-linking — acceptable, since that internal link wouldn't
resolve for that viewer anyway; explicit `/issues/IDENT` references
still render.
- During initial load (companies not yet fetched) behavior is identical
to today (permissive), so no new flicker.

## Model Used

- **Anthropic Claude Opus 4.8** (`claude-opus-4-8`), 1M-token context,
extended thinking + tool use. Plan authored and implemented with the
model; all decisions reviewed by the human contributor.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes (no
doc changes required — behavior gated, no public API/doc surface
affected)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:36:29 -07:00
Aron PrinsandClaude Opus 4.7 e85ff094ec fix(ui): invite page goes blank from companies query-key collision (#6433)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies; humans
operate the board through the React UI.
> - The board gates company access via `CompanyProvider`
(CompanyContext) and onboards new humans through the invite landing page
at `/invite/:token`.
> - Reported symptom: opening an invite link and signing in works, but
the page then renders completely blank (black in dark mode).
> - End-to-end browser testing reproduced a client-side crash:
`companiesQuery.data?.some is not a function` and `Cannot read
properties of undefined (reading 'filter')`.
> - Root cause: `CompanyProvider` and `InviteLandingPage` both use the
React Query key `["companies"]` but return **different shapes** — `{
companies, unauthorized }` vs a bare `Company[]` — so they silently
corrupt the shared cache entry; whichever component reads the other's
shape calls `.some()`/`.filter()` on the wrong type and throws,
unmounting the tree.
> - Owners never hit it (they never mount the invite page); only
invitees landing on `/invite/:token` crash.
> - This PR unifies the `["companies"]` query into a single shared
definition so the cache entry always has one shape and the two consumers
can't drift apart again.
> - The benefit is a working invite/onboarding flow and removal of a
whole class of cache-shape bugs on this key.

## What Changed

- Add `ui/src/api/companies-query.ts` exporting a single shared
`companiesListQueryOptions` (and `CompanyListResult`) — one `queryKey` +
one `queryFn` that always returns the wrapped `{ companies, unauthorized
}` shape, documented with the shared-cache contract.
- `ui/src/context/CompanyContext.tsx` now uses
`useQuery(companiesListQueryOptions)` instead of an inline copy of that
query.
- `ui/src/pages/InviteLanding.tsx` uses the same
`companiesListQueryOptions` (with its own `enabled` gate), reads
`companiesQuery.data?.companies` for the membership checks, and uses
`queryClient.fetchQuery(companiesListQueryOptions)` in the post-auth
path — so it reads and writes the identical shape.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `vitest run src/pages/InviteLanding.test.tsx
src/context/CompanyContext.test.tsx` — 17/17 pass, unchanged.
- Manual end-to-end via a real browser against a LAN-exposed
authenticated instance:
  - Owner creates an Owner-role invite.
- New user opens the link and registers — **the "awaiting approval"
screen renders** (previously blank), `POST /api/invites/:token/accept`
returns `202`, no console errors.
- Owner approves at Company Settings → Access (`200`); invitee becomes
an active member.
- Invitee signs in — full board loads; smoke test of dashboard / issues
/ inbox / routines / goals / company settings — all render, zero
`pageerror`s.
- Before: invite page `#root` empty after sign-in (blank/black). After:
awaiting-approval panel renders. (Screenshots available on request.)

## Risks

- Low. `CompanyProvider`'s query behavior is unchanged (same `queryFn`
logic, just extracted into a shared module). `InviteLandingPage` now
reads the same shape it writes. No API, schema, or migration changes.
Existing tests pass unchanged.

## Model Used

- Claude (Anthropic), model ID `claude-opus-4-7` (Opus 4.7), 1M-context,
extended thinking + tool use; driving Claude Code with browser
automation for end-to-end reproduction and verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable (existing
InviteLanding/CompanyContext tests cover the touched code and pass; a
cross-provider regression test that mounts both consumers is a sensible
follow-up)
- [ ] If this change affects the UI, I have included before/after
screenshots (described textually above; this is a crash/blank-page fix,
screenshots available on request)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:28:49 -05:00
DottaandPaperclip d95968a9f8 [codex] Ignore stale stored company selections (#4602)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The board UI is the operator’s control surface for selecting the
active company
> - A company id stored in localStorage can become stale across resets,
imports, or deleted companies
> - Exposing that stale id before companies load can briefly put
downstream UI in an invalid company scope
> - This pull request defers selected-company exposure until the loaded
company list validates the stored id
> - The benefit is a cleaner company-selection bootstrap path and fewer
transient invalid API requests

## What Changed

- Initialized `CompanyProvider` selection as `null` until companies
finish loading.
- Reused a stored company id only when it exists in the loaded
selectable company list.
- Cleared storage and selected state when no companies are available.
- Added jsdom regression coverage for stale stored ids before and after
company loading.

## Verification

- `pnpm exec vitest run --project @paperclipai/ui
ui/src/context/CompanyContext.test.tsx`

## Risks

- Low risk. The change only affects selection bootstrap and keeps valid
stored selections intact.
- There may be a slightly longer initial `null` selected-company state
while the company list is loading.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled terminal/GitHub
workflow, reasoning mode active. Context window not exposed in this
environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-27 13:18:21 -05:00
Dotta e538329b0a Use asset-backed company logos 2026-03-16 09:25:39 -05:00
Dotta 1a5eaba622 Merge public-gh/master into review/pr-162 2026-03-16 08:47:05 -05:00
DottaandPaperclip 2b9de934e3 Fix manual company switch route sync
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:47:01 -05:00
JonCSykes b19d0b6f3b Add support for company logos, including schema adjustments, validation, assets handling, and UI display enhancements. 2026-03-06 16:39:35 -05:00
DottaandClaude Opus 4.6 f60c1001ec refactor: rename packages to @paperclipai and CLI binary to paperclipai
Rename all workspace packages from @paperclip/* to @paperclipai/* and
the CLI binary from `paperclip` to `paperclipai` in preparation for
npm publishing. Bump CLI version to 0.1.0 and add package metadata
(description, keywords, license, repository, files). Update all
imports, documentation, user-facing messages, and tests accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:45:26 -06:00
Dotta 8ee063c4e5 feat(ui): reconcile backup UI changes with current routing and interaction features 2026-03-02 16:44:03 -06:00
ForgottenandClaude Opus 4.6 410164a632 feat(ui): company-prefix routes, archive company, hide archived from sidebar
Support optional company-prefix in URL paths (e.g. /PAP/issues/PAP-1).
Filter archived companies from sidebar rail, switcher, and auto-select.
Add archive button to company settings with confirmation dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:31:54 -06:00
ForgottenandClaude Opus 4.6 2327cfb861 feat(ui): make company rail items proper links for right-click support
Company rail icons are now <a> tags with href instead of <button>, enabling
right-click "Open in new tab". CompanyContext reads a ?company= URL param on
init so the correct company is selected when opened in a new tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:41:59 -06:00
ForgottenandClaude Opus 4.6 2ec45c49af feat(ui): add auth pages, company rail, inbox redesign, and page improvements
Add Auth sign-in/sign-up page and InviteLanding page for invite acceptance.
Add CloudAccessGate that checks deployment mode and redirects to /auth when
session is required. Add CompanyRail with drag-and-drop company switching.
Add MarkdownBody prose renderer. Redesign Inbox with category filters and
inline join-request approval. Refactor AgentDetail to overview/configure/runs
views with claude-login support. Replace navigate() anti-patterns with <Link>
components in Dashboard and MetricCard. Add live-run indicators in sidebar
agents. Fix LiveUpdatesProvider cache key resolution for issue identifiers.
Add auth, health, and access API clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:41:21 -06:00
ForgottenandClaude Opus 4.6 3dc3813266 Adopt React Query and live updates across all UI pages
Replace custom useApi/useAgents hooks with @tanstack/react-query.
Add LiveUpdatesProvider for WebSocket-driven cache invalidation.
Add queryKeys module for centralized cache key management. Rework
all pages and dialogs to use React Query mutations and queries.
Improve CompanyContext with query-based data fetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:24:48 -06:00
ForgottenandClaude Opus 4.6 22e7930d0b Overhaul UI with shadcn components and new pages
Add shadcn/ui components (badge, button, card, input, select,
separator). Add company context provider. New pages: Activity,
Approvals, Companies, Costs, Org chart. Restyle existing pages
(Dashboard, Agents, Issues, Goals, Projects) with shadcn components
and dark theme. Update layout, sidebar navigation, and routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:07:32 -06:00