Appearance
Tellent Assistant Mobile Link Handling Implementation Plan
Overview
Give the Tellent Assistant chat panel a mobile-aware link strategy. Today every internally-generated link is a web /switch URL rendered as <a target="_blank">, which on the Capacitor mobile app punts the user into a browser showing the non-responsive Recruitee web app. This plan introduces a host-provided link-opener abstraction: on recruitee-mobile, assistant links either navigate in-app (via the existing deeplink navigation machinery) or present a "Switch to desktop" bottom sheet for features the mobile app doesn't have. Web apps are untouched.
Ticket: CU-86cb1d4k7. Branch: feat/CU-86cb1d4k7-assistant-mobile-links.
Current State Analysis
How assistant links are generated (verified)
- Core builder:
injectTellentAppUrlBuilder()inlibs/tellent/util/lib/helpers/tellent-app-link-handler.tsbuilds${tellentAuthUrl}/switch?app_kind=<kind>&org_guid=<guid>&page=<encoded JSON TellentPageConfig>. Thepageparam is an abstract route descriptor (TellentAppRouteID+ params, defined inlibs/tellent/types/lib/routes.ts), resolved by the target web app (tellentAssistantRoutesindomains/recruitee/apps/recruitee/src/app/main/app-shell.ts:167). - Entity list cards:
injectAssistantEntityUrl()(libs/tellent-assistant/smart-ui/lib/present-list/assistant-entity-url.ts) wraps the builder; used by 8 list-item components underlibs/tellent-assistant/smart-ui/lib/present-list/items/. The URL flows intoListCardComponent(present-list/list-card/list-card.component.ts) which renders<a class="open-link" [href] target="_blank">. - Action result links:
TellentAssistantActionsService.storeResultLink()(libs/tellent-assistant/logic/lib/tellent-assistant-actions.service.ts:153) builds URLs from backend{route, params, label}; rendered by the success banner inlibs/tellent-assistant/ui/lib/parts/proposal-preview/proposal-preview.component.ts:112as<a [href] target="_blank">. The link reaches the UI via the chat panel'sresultLinkResolverinput ontellent-assistant-message. - Chat panel imperative navigation:
openIntelligenceSettings()(tellent-assistant-chat-panel.component.ts:330) assigns a TellentAdmin/switchURL towindow.location;openHelpCenter()opens an external help-center URL. - Pass-through URLs:
list-item-link/list-item-sourcerender URLs verbatim from tool data (genuinely external; browser is correct for these).
Key facts discovered
offerIdandtalentPoolIdcarry slugs, not ids (list-item-job.component.ts:27passesdata().slug; same for talent pools). The mobile routesjobs/:slugandtalent-pools/:slugtherefore map directly.event/evaluationpage configs carry onlycandidateId(web opens the candidate dialog on a tab), but the list items hold the entity id (EventData.id,EvaluationData.id) and the mobile app has dedicatedevents/:id/evaluations/:idscreens.- Mobile routes available (
domains/recruitee/apps/recruitee-mobile/src/app/app-routing.ts):candidates/:id,jobs/:slug,mailbox/:id,events/:id,tasks/:id,evaluations/:id,talent-pools/:slug. No screens for requisitions, team members, calendar, or any Tellent Admin/HR target. DeeplinksService(recruitee-mobile/src/app/services/deeplinks.service.ts) owns theDeeplinkRoutevocabulary andonDeeplinkRoute()navigation, but currently lacksevent/task/evaluationroute types and exposes no public API to publish an already-parsed route.- Sheet infrastructure is ready:
TellentMobileSheetPresentertoken (libs/tellent-mobile/ui/lib/sheet-presenter.tokens.ts) is provided byrecruitee-mobile(tellent-mobile-ui.providers.ts) viacreateIonModalSheetPresenter;background: 'default'maps to the existing.tellent-sheet-modalglobal class (src/theme/dialogs.less:52), documented as "matches the confirmation sheet (plain surface)".ThreadOptionsSheetService(libs/tellent-assistant/smart-ui/lib/tellent-assistant-chat-panel/thread-options-sheet.service.ts) is the reference for presenting a shared component through it. - Token pattern:
createProviderTokenfrom@core/common-legacy, as inlibs/tellent-assistant/contracts/lib/host-tokens.token.ts. - Assistant hosts: recruitee (web), tellent-admin/tellent-client (via
hr-assistant), recruitee-mobile. Only recruitee-mobile will provide the opener; all others keep anchor-default behavior.
Desired End State
On recruitee-mobile, activating any assistant-generated link:
- Navigates in-app for: candidate, job (offer), talent pool, task, event, evaluation, dashboard/home.
- Presents a "Switch to desktop" bottom sheet (per design: title, message, full-width "Got it" button) for everything else — requisitions, team members, calendar, all cross-app targets (Tellent Admin/HR), and the chat panel's "Intelligence settings" entry.
- Never opens the Recruitee web app in a browser.
On web apps, behavior is byte-for-byte unchanged (anchors with /switch hrefs).
Verification: run the mobile app, ask the assistant to list jobs/candidates/tasks/events, tap each card → in-app navigation; ask for requisitions → tap card → sheet appears; confirm an action with a result link → banner link navigates in-app; open assistant menu → Intelligence settings → sheet.
What We're NOT Doing
- No Tellent HR mobile app custom-scheme handoff (AppLauncher). Cross-app targets get the sheet; scheme handoff is a possible follow-up.
- No changes to
openHelpCenter()— the help center is an external responsive site; system browser is acceptable. - No interception of genuinely external URLs (
list-item-link,list-item-source) — those keep opening in the browser. - No parsing of
event/task/evaluationfrom incoming URL deeplinks (DeeplinksService.parseRoute) — only the route vocabulary and navigation switch are extended; push-notification URL parsing for these can be added later. - No company-switch handling in the opener: assistant links always carry the current org's
ORG_GUID, so cross-company targets cannot occur from this path. - No changes to the tellent-hr-mobile app (it does not host the assistant chat panel today).
Implementation Approach
Keep the web /switch URL as the universal carrier (hrefs, accessibility, copy-link) and add one optional, host-provided opener consulted at click time. The structured (appKind, page) pair every link already has pre-flattening becomes a first-class AssistantLinkTarget, enriched with an optional context.entityId that never leaks into the href but lets mobile prefer its dedicated detail screens.
Dependency direction: type in @core/tellent-assistant-types → token in @core/tellent-assistant-contracts → interception directive in @core/tellent-assistant-ui → used by smart-ui and ui render sites → implementation provided only by recruitee-mobile.
Phase 1: Link-target abstraction in shared assistant libs
Overview
Introduce AssistantLinkTarget, the opener token, and an anchor directive that intercepts clicks when an opener is present. Rewire all internal link render sites to carry the structured target. No behavior change anywhere (no app provides the opener yet).
Changes Required:
1. Target type
File: libs/tellent-assistant/types/lib/link-target.types.ts (new; export from libs/tellent-assistant/types/index.ts)
typescript
import type { TellentAppKindParam, TellentAppRouteID, TellentPageConfig } from '@core/tellent-types';
export interface AssistantLinkTargetContext {
/**
* Entity id when the page config doesn't carry it (e.g. `event`/`evaluation`
* page configs only hold candidateId while the source data has the entity id).
* Consumed by native hosts; never serialized into the web URL.
*/
entityId?: string;
}
export interface AssistantLinkTarget {
appKind: TellentAppKindParam;
page: TellentPageConfig<TellentAppRouteID>;
/** The built web /switch URL — always valid as href fallback. */
url: string;
context?: AssistantLinkTargetContext;
}2. Opener token
File: libs/tellent-assistant/contracts/lib/link-opener.token.ts (new; export from contracts index.ts)
typescript
import { createProviderToken } from '@core/common-legacy';
import type { AssistantLinkTarget } from '@core/tellent-assistant-types';
/** Returns true when it handled the activation (caller must preventDefault). */
export type TellentAssistantLinkOpener = (target: AssistantLinkTarget) => boolean;
export const {
provide: provideTellentAssistantLinkOpener,
inject: injectTellentAssistantLinkOpener,
} = createProviderToken<TellentAssistantLinkOpener>('TELLENT_ASSISTANT_LINK_OPENER')(false);3. Interception directive
File: libs/tellent-assistant/ui/lib/link/assistant-link.directive.ts (new; export from ui index.ts)
typescript
@Directive({
selector: 'a[tellentAssistantLink]',
host: {
'[attr.href]': 'tellentAssistantLink()?.url',
'(click)': 'onClick($event)',
},
})
export class TellentAssistantLinkDirective {
private readonly opener = injectTellentAssistantLinkOpener({ optional: true });
public readonly tellentAssistantLink = input.required<AssistantLinkTarget | null>();
protected onClick(event: MouseEvent): void {
const target = this.tellentAssistantLink();
if (target && this.opener?.(target)) {
event.preventDefault();
}
}
}4. Target builder (replaces URL-only helper)
File: libs/tellent-assistant/smart-ui/lib/present-list/assistant-entity-url.tsChanges: injectAssistantEntityUrl() becomes injectAssistantLinkTarget() returning a full AssistantLinkTarget; accepts optional context.
typescript
export function injectAssistantLinkTarget() {
const build = injectTellentAppUrlBuilder();
const orgGuid = inject(ORG_GUID, { optional: true });
return (
appKind: TellentAppKindParam,
page: TellentPageConfig<TellentAppRouteID>,
context?: AssistantLinkTargetContext,
): AssistantLinkTarget => ({
appKind,
page,
url: build(appKind, page, orgGuid ?? undefined),
...(context ? { context } : {}),
});
}5. List card
File: libs/tellent-assistant/smart-ui/lib/present-list/list-card/list-card.component.tsChanges: add linkTarget input rendered through the directive; keep url input for raw external links (list-item-link, list-item-source), which stay uninterceptable.
typescript
@if (linkTarget(); as target) {
<a class="open-link" [tellentAssistantLink]="target" target="_blank"
rel="noopener noreferrer" [attr.aria-label]="label()"></a>
} @else if (url(); as href) {
<a class="open-link" [href]="href" target="_blank"
rel="noopener noreferrer" [attr.aria-label]="label()"></a>
}Host class binding becomes '[class.interactive]': '!!url() || !!linkTarget()'.
6. Entity list items (8 components)
Files: libs/tellent-assistant/smart-ui/lib/present-list/items/list-item-{candidate,job,requisition,talent-pool,task,event,evaluation,team-member}/*.component.ts (+ templates binding [linkTarget] instead of [url]) Changes: compute AssistantLinkTarget via injectAssistantLinkTarget(). Page configs unchanged. event and evaluation additionally pass { entityId: this.data().id } as context (for event, only in the candidate-bound branch; the calendar branch passes no context).
7. Action result links
File: libs/tellent-assistant/logic/lib/tellent-assistant-actions.service.tsChanges: ActionResultLink gains target: AssistantLinkTarget; storeResultLink composes it (appKind: AppKind.Recruitee, page from link.route/link.params, url as today; no context available from backend payloads).
File: libs/tellent-assistant/ui/lib/parts/proposal-preview/proposal-preview.component.tsChanges: ProposalResultLink gains optional target?: AssistantLinkTarget; the banner anchor uses [tellentAssistantLink]="link.target" when present, plain [href] otherwise. Import the directive.
File: libs/tellent-assistant/smart-ui/lib/tellent-assistant-chat-panel/tellent-assistant-chat-panel.component.tsChanges: resultLinkResolver passes the target through (ProposalResultLink shape).
8. Chat panel imperative navigation
File: libs/tellent-assistant/smart-ui/lib/tellent-assistant-chat-panel/tellent-assistant-chat-panel.component.tsChanges: openIntelligenceSettings() consults the opener first:
typescript
protected openIntelligenceSettings(): void {
if (!this.orgGuid) return;
const page = <TellentPageConfig<TellentAppRouteID>>{ id: 'intelligence' };
const url = this.urlBuilder(AppKind.TellentAdmin, page, this.orgGuid);
if (this.linkOpener?.({ appKind: AppKind.TellentAdmin, page, url })) return;
this.windowRef.nativeWindow.location = url;
}with private readonly linkOpener = injectTellentAssistantLinkOpener({ optional: true });.
Success Criteria:
Automated Verification:
- [ ] Type checks pass:
NODE_OPTIONS=--max_old_space_size=8192 npx tsc --noEmit -p domains/recruitee/apps/recruitee/tsconfig.jsonand... -p domains/recruitee/apps/recruitee-mobile/tsconfig.json - [ ] Affected tests pass:
npx nx affected -t test --base origin/master(notablycore-tellent-assistant-testingchat-panel harness specs) - [ ] Lint passes:
pnpm run lint - [ ] New directive spec: opener absent → click not prevented; opener returns true → prevented; opener returns false → not prevented (plain TestBed + vitest — Spectator is unreliable in libs specs)
Manual Verification:
- [ ] Recruitee web app: assistant entity cards, result-link banners, and Intelligence settings behave exactly as before (hrefs unchanged,
target="_blank")
Phase 2: TellentMobileConfirmationSheetComponent in tellent-mobile-ui
Overview
Shared bottom-sheet confirmation component mirroring recruitee-mobile's ConfirmationModal (src/app/modules/modals/confirmation-modal/), the same way TellentMobileOptionsSheetComponent mirrors OptionsModal. Scope per design: title, message, single full-width primary button. No checkboxes/note/save/cancel variants.
Changes Required:
1. Component
Files: libs/tellent-mobile/ui/lib/confirmation-sheet/confirmation-sheet.component.{ts,html,less} (+ spec; export component from libs/tellent-mobile/ui/index.ts)
typescript
@Component({
selector: 'tlm-confirmation-sheet',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './confirmation-sheet.component.html',
styleUrl: './confirmation-sheet.component.less',
})
export class TellentMobileConfirmationSheetComponent {
public readonly title = input.required<string>();
public readonly message = input('');
public readonly confirmLabel = input.required<string>();
// Callback alternative to the output for overlay-controller hosts,
// matching TellentMobileOptionsSheetComponent's convention.
public readonly confirmHandler = input<() => void>();
public readonly confirmed = output<void>();
protected confirm(): void {
this.confirmed.emit();
this.confirmHandler()?.();
}
}Template/styles mirror confirmation.modal.html minus checkboxes/save/cancel: .heading-4-equivalent title, message paragraph, full-width primary button. Follow the options-sheet component's styling approach (theme variables, :host scoping); presented with background: 'default' so the existing .tellent-sheet-modal chrome applies — no new global CSS.
Success Criteria:
Automated Verification:
- [ ]
npx nx test core-tellent-mobile-uipasses (component spec: renders title/message, confirm click fires output + handler) - [ ] Lint passes:
pnpm run lint
Manual Verification:
- [ ] Sheet visually matches the "Switch to desktop" design on phone (bottom sheet) and presents as centered dialog on tablet (via
presentAsDialogin the presenter)
Phase 3: Extend mobile deeplink navigation vocabulary
Overview
Add event, task, evaluation to DeeplinkRoute and expose a public entry point so the opener can publish already-constructed routes through the same gated pipeline (pending-route buffering via redirectsEnabled, stack handling).
Changes Required:
1. Route types + navigation
File: domains/recruitee/apps/recruitee-mobile/src/app/services/deeplinks.service.tsChanges:
- Extend the
DeeplinkRouteunion:
typescript
| { type: 'event'; id: number }
| { type: 'task'; id: number }
| { type: 'evaluation'; id: number }(each & CompanyAssigned like the existing members).
- Extend
onDeeplinkRouteswitch:
typescript
case 'event':
this.navService.push(['events', route.id.toString()]);
return;
case 'task':
this.navService.push(['tasks', route.id.toString()]);
return;
case 'evaluation':
this.navService.push(['evaluations', route.id.toString()]);
return;- Add a public publish method for in-app callers:
typescript
/** Publish an already-parsed route through the same gating as URL deeplinks. */
public open(route: DeeplinkRoute): void {
this.publishRoute(route);
}Success Criteria:
Automated Verification:
- [ ] Type check passes:
NODE_OPTIONS=--max_old_space_size=8192 npx tsc --noEmit -p domains/recruitee/apps/recruitee-mobile/tsconfig.json - [ ]
npx nx affected -t test --base origin/masterpasses
Manual Verification:
- [ ] No regressions in existing deeplinks (push notification → candidate, job)
Phase 4: Mobile link opener + "Switch to desktop" sheet service
Overview
Implement the opener in recruitee-mobile: map AssistantLinkTarget → DeeplinkRoute where possible, otherwise present the confirmation sheet. Provide the token from provideTellentAssistantMobile().
Changes Required:
1. Opener service
File: domains/recruitee/apps/recruitee-mobile/src/app/services/assistant-link-opener.service.ts (new)
typescript
@Injectable({ providedIn: 'root' })
export class AssistantLinkOpenerService {
private readonly deeplinks = inject(DeeplinksService);
private readonly i18n = inject(I18nService);
private readonly presenter = injectTellentMobileSheetPresenter({ optional: true });
/** Always returns true on mobile: navigate in-app or show the sheet — never the browser. */
public open(target: AssistantLinkTarget): boolean {
const route = this.toDeeplinkRoute(target);
if (route) {
this.deeplinks.open(route);
} else {
void this.presentSwitchToDesktopSheet();
}
return true;
}
private toDeeplinkRoute(target: AssistantLinkTarget): DeeplinkRoute | null {
if (target.appKind !== AppKind.Recruitee) return null;
const { page, context } = target;
const assigned = { orgGuid: null, company: null };
switch (page.id) {
case 'candidate':
return { type: 'candidate', id: Number(page.params.candidateId), ...assigned };
case 'offer':
// offerId carries the job slug (see list-item-job), matching jobs/:slug
return { type: 'job', slug: page.params.offerId, tab: 'pipeline', ...assigned };
case 'talentPool':
// talentPoolId carries the slug, matching talent-pools/:slug
return { type: 'talent-pool', slug: page.params.talentPoolId, tab: 'candidates', ...assigned };
case 'task':
return { type: 'task', id: Number(page.params.taskId), ...assigned };
case 'event':
return context?.entityId
? { type: 'event', id: Number(context.entityId), ...assigned }
: { type: 'candidate', id: Number(page.params.candidateId), ...assigned };
case 'evaluation':
return context?.entityId
? { type: 'evaluation', id: Number(context.entityId), ...assigned }
: { type: 'candidate', id: Number(page.params.candidateId), ...assigned };
case 'home':
return { type: 'dashboard', ...assigned };
default:
// requisition, team_members, calendar, settings pages, anything unknown
return null;
}
}
private async presentSwitchToDesktopSheet(): Promise<void> {
if (!this.presenter) return;
const handle = await this.presenter.createSheet({
component: TellentMobileConfirmationSheetComponent,
background: 'default',
canDismiss: true,
componentProps: {
title: this.i18n.t('assistant.switch_to_desktop.title'),
message: this.i18n.t('assistant.switch_to_desktop.message'),
confirmLabel: this.i18n.t('assistant.switch_to_desktop.confirm'),
confirmHandler: () => void handle.dismiss(),
},
});
await handle.present();
}
}(Adapt the numeric-id guards: if Number(...) yields NaN, return null so the sheet shows instead of a broken navigation. Match I18nService's actual translate method name.)
2. Provider wiring
File: domains/recruitee/apps/recruitee-mobile/src/app/tellent-assistant.providers.tsChanges: add to provideTellentAssistantMobile():
typescript
provideTellentAssistantLinkOpener({
useFactory: () => {
const opener = inject(AssistantLinkOpenerService);
return (target: AssistantLinkTarget) => opener.open(target);
},
}),3. Translations
File: domains/recruitee/apps/recruitee-mobile/src/locales/en.json (English only; other locales follow via the Phrase workflow)
json
"assistant": {
"switch_to_desktop": {
"title": "Switch to desktop",
"message": "To use this feature, please log in to the web app. Your chat history will be waiting for you.",
"confirm": "Got it"
}
}(Merge into the existing structure; adjust nesting to match the file's conventions.)
Success Criteria:
Automated Verification:
- [ ] Opener service spec (
npx nx test recruitee-mobileor affected): each route ID maps to the expectedDeeplinkRoute;requisition/TellentAdmin/calendar/NaN ids → sheet path;eventwith and withoutcontext.entityId - [ ] Type checks + lint pass (commands as in previous phases)
Manual Verification (iOS simulator / Android emulator, pnpm run ios / pnpm run android):
- [ ] Candidate/job/talent-pool/task cards navigate to the correct in-app screens
- [ ] Event and evaluation cards open
events/:id/evaluations/:id - [ ] Requisition card presents the "Switch to desktop" sheet; "Got it" dismisses it
- [ ] Confirmed action's result-link banner navigates in-app (e.g. created task)
- [ ] Assistant menu → Intelligence settings presents the sheet (no
window.locationnavigation) - [ ] Source/link cards (external URLs) still open in the browser
- [ ] Web assistant (recruitee app via
pnpm start) unchanged
Testing Strategy
Unit Tests:
TellentAssistantLinkDirective: click interception matrix (no opener / handled / unhandled). Plain TestBed + vitest.TellentMobileConfirmationSheetComponent: rendering + confirm behavior.AssistantLinkOpenerService: full mapping table incl. fallback cases and slug-vs-id params.- Existing
chat-panel.spec.tsharness (libs/tellent-assistant/testing): update forlinkTargetinput rename on list cards; add a case asserting hrefs still render without an opener.
Manual Testing Steps:
- Mobile: exercise every card type via assistant prompts ("show me open jobs", "list my tasks", "any interviews this week?", "list requisitions").
- Mobile: confirm a proposed action that returns a result link; tap the banner.
- Web: spot-check the same flows for unchanged behavior.
Performance Considerations
None material — one optional injection per render site and a synchronous mapping on click.
Migration Notes
injectAssistantEntityUrl→injectAssistantLinkTargetis a rename with a richer return type; all call sites are insidelibs/tellent-assistant/smart-uiand updated in Phase 1. Grep for stragglers before finishing:grep -rn "injectAssistantEntityUrl" libs domains.ListCardComponent.urlstays for external links, solist-item-link/list-item-sourceneed no changes.
Deviations During Implementation
- Opener token lives in
@core/tellent-assistant-ui, not contracts (libs/tellent-assistant/ui/lib/link/link-opener.token.ts): theboundaries/element-typesESLint rule forbids ui → contracts dependencies, and the interception directive (ui) must inject the token. Precedent:tellent-mobile-uikeeps its sheet-presenter tokens in the ui lib. - No unit spec for
AssistantLinkOpenerService: therecruitee-mobileproject has no test target and zero spec files, so the mapping is covered by the shared-lib specs (directive, sheet) plus manual verification. If a test target is ever added, the puretoDeeplinkRoutemapping is the first candidate. openIntelligenceSettingsuses a{ id: <const>'intelligence' }literal instead of aTellentPageConfigcast (lint:consistent-type-assertions, and the file rides the 300-linemax-lineslimit).
References
- Design: "Switch to desktop" bottom sheet (attached to ticket; screenshot reviewed 2026-08-05)
- Mirroring precedent:
TellentMobileOptionsSheetComponent+ThreadOptionsSheetService - Route vocabulary:
libs/tellent/types/lib/routes.ts; web resolution:domains/recruitee/apps/recruitee/src/app/main/app-shell.ts