update draft issue

This commit is contained in:
2026-08-15 23:24:06 +05:30
parent b349f2db61
commit 69b38abced
8 changed files with 753 additions and 25 deletions

View File

@@ -77,6 +77,23 @@ export function AssistantPanelProvider({ role, pathname, children }) {
}
});
/**
* A question the page handed to the panel, waiting to be asked.
*
* The one thing a page could not do before: publish what it is looking at
* (PageContext) but never *start* a turn about it. So a control whose work
* lives in the conversation — Continue, on a draft card — had no way to reach
* the conversation, and the only thing left to do with it was navigate to a
* form. This is that missing direction, kept as narrow as the read channel:
* serializable state, no callbacks into the panel, and the panel decides when
* to take it.
*
* State rather than an event, because the panel is unmounted while collapsed —
* `ask` opens it, and the request is still here when it mounts.
*/
const [request, setRequest] = React.useState(null);
const requestSeq = React.useRef(0);
const context = React.useMemo(
() => resolveAssistantContext(role, pathname),
[role, pathname]
@@ -118,6 +135,27 @@ export function AssistantPanelProvider({ role, pathname, children }) {
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
/**
* Asks Owliver something on the page's behalf, opening the panel to receive it.
*
* `positionId` names the record the control was built from, so the answer is
* about that position rather than about whichever one the wording happens to
* match. Each call carries its own id, so asking the same question twice is
* two turns rather than one ignored repeat.
*/
const ask = React.useCallback(({ question, positionId = null }) => {
const text = String(question || '').trim();
if (!text) return;
requestSeq.current += 1;
setRequest({ id: requestSeq.current, question: text, positionId });
setOpenState(true);
}, [setOpenState]);
/** Taken by the panel once it has actually asked it. */
const consumeRequest = React.useCallback((id) => {
setRequest((current) => (current?.id === id ? null : current));
}, []);
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
@@ -148,7 +186,11 @@ export function AssistantPanelProvider({ role, pathname, children }) {
toggle: () => setOpenState(!isOpen),
expand: () => setExpandedState(true),
restore: () => setExpandedState(false),
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
ask,
request,
consumeRequest,
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
ask, request, consumeRequest]);
/* The page's own selection travels beside the window state, mounted here so
both the page and the panel are inside it — a skill's data source resolves
@@ -182,5 +224,8 @@ export function useAssistantPanel() {
toggle: () => {},
expand: () => {},
restore: () => {},
ask: () => {},
request: null,
consumeRequest: () => {},
};
}

View File

@@ -8,7 +8,8 @@ import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
@@ -19,6 +20,7 @@ import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { groupByRecency } from './history';
import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
@@ -214,6 +216,9 @@ export default function KrowAssistant({
const userName = useCurrentUserName();
const navigate = useNavigate();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = useAssistantPanel();
/* The app's own router, not a location assignment: a full page load would
discard the thread and the panel state along with it. */
const goToPage = React.useCallback(
@@ -320,6 +325,30 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* Finishing a draft, through the mutations the Create Position form calls.
*
* `useUpdateJobPosting` is the same update the form and the Positions page
* use, and `useGenerateJobDescription` is the same generator behind the
* form's "Generate Job Description with AI" button. Reusing both is what
* keeps one position record, one description and one set of vetting weights
* — the panel changes the record, it does not keep a copy of it.
*/
const updateJob = useUpdateJobPosting();
const onUpdatePosition = React.useCallback(
(id, data) => updateJob.mutateAsync({ id, data }),
[updateJob]
);
const generateDescription = useGenerateJobDescription();
const onGenerateDescription = React.useCallback(
async (position) => {
const result = await generateDescription.mutateAsync({ data: position, draftId: position.id });
return result || null;
},
[generateDescription]
);
/**
* The workforce picture the panel reasons over.
*
@@ -386,6 +415,8 @@ export default function KrowAssistant({
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onUpdatePosition,
onGenerateDescription,
onAssignWorkers,
onScheduleInterview,
workforce,
@@ -495,14 +526,14 @@ export default function KrowAssistant({
* bypasses intent routing. A typed question carries none and is routed. Both
* land in the same `send`, so there is a single pipeline to reason about.
*/
const handleMessage = React.useCallback((question, capability = null) => {
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
const text = String(question).trim();
if (!text || busy) return;
setInput('');
/* Asking something while reading History returns to the conversation — the
answer is about to arrive there, and leaving the list up would hide it. */
setView('chat');
send({ question: text, capability });
send({ question: text, capability, positionId });
}, [busy, send]);
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
@@ -519,9 +550,26 @@ export default function KrowAssistant({
navigate(prompt.route);
return;
}
handleMessage(prompt.prompt, prompt.capability);
/* A chip built from a position says which one, so a follow-up acts on the
record the previous answer was about rather than re-deriving it from the
words. */
handleMessage(prompt.prompt, prompt.capability, prompt.positionId);
}, [handleMessage, navigate]);
/**
* A question the page asked on the reader's behalf.
*
* Continue on a draft card is the only source today: the card knows which
* record it is, and the work of finishing it lives here. Deferred while a
* response is streaming — the effect re-runs when the panel is free, so the
* request waits its turn rather than being dropped for arriving mid-answer.
*/
React.useEffect(() => {
if (!panelRequest || busy) return;
handleMessage(panelRequest.question, null, panelRequest.positionId);
consumeRequest(panelRequest.id);
}, [panelRequest, busy, handleMessage, consumeRequest]);
const composer = (
<PromptInput
value={input}

View File

@@ -12,6 +12,9 @@ import { pct, plural, verb } from '../insights';
one thing in the library and another in the panel beside it. */
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
import { toFICO } from '@/lib/talentHome';
/* The vetting criteria's own labels. Read from the position model rather than
restated here, so the panel calls a weight what the form calls it. */
import { CRITERIA_LABELS } from '@/lib/positionModel';
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
/**

View File

@@ -451,11 +451,18 @@ const PROMPTS = {
*/
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
if (drafts.length) {
out.push({
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
prompt: 'Which positions are still in draft?',
capability: 'position-drafts',
});
out.push(drafts.length === 1
/* Names the draft, so the answer continues *that* record in the panel
rather than listing every draft and sending the reader to a form. */
? {
label: `Continue the ${drafts[0].title} draft`,
prompt: `Continue the ${drafts[0].title} draft`,
}
: {
label: `${drafts.length} drafts to finish`,
prompt: 'Which positions are still in draft?',
capability: 'position-drafts',
});
}
const current = workforce?.currentPositionId

View File

@@ -7,6 +7,10 @@ import {
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
responseTitle, summaryLines,
} from '@/lib/skills/owliverResolver';
import {
draftActions, draftContinuedReply, explainWeightsReply, matchDraftIntent, noDraftReply,
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
} from '@/lib/skills/draftFlow';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
@@ -707,12 +711,121 @@ function resolveSkill({
};
}
/**
* A draft question, resolved against the position records the panel holds.
*
* Returns an intent the panel executes. The two that write — generating a
* description and setting weights, plus publishing — carry a `perform` block
* rather than performing anything here: this module stays pure, and the write
* goes through the mutation the Create Position form already uses.
*/
function resolveDraftAction(question, workforce, positionId = null) {
const action = matchDraftIntent(question);
if (!action) return null;
const positions = workforce.positions || [];
/* `positionId` is the record a control was built from — the Continue button on
a card, or a chip this flow wrote. It settles the question before any
guessing from the wording starts. */
const { position, ambiguous } = resolveDraft(
question, positions, workforce.currentPositionId, positionId
);
if (ambiguous) return { kind: 'draft', ...whichDraftReply(ambiguous) };
/**
* Nothing resolved. Only a question explicitly about drafts is answered with
* "there are none" — everything else is handed back.
*
* "Summarize these vetting weights" on the Create Position form is a question
* about the form, and the page has answered it since long before drafts could
* be continued here. Claiming it because it contains the word "weights" and
* then reporting that no draft exists would be this flow taking a question it
* cannot answer.
*/
if (!position) {
return action === 'continue' || action === 'list'
? { kind: 'draft', doc: noDraftReply() }
: null;
}
switch (action) {
/* One draft is continued; several are offered by name. Either way the
answer is here, and neither opens a form. */
case 'list':
return positions.filter((p) => p.status === 'draft').length > 1
? { kind: 'draft', ...whichDraftReply(positions.filter((p) => p.status === 'draft')) }
: {
kind: 'draft',
doc: draftContinuedReply(position),
followUp: draftActions(position),
};
case 'continue':
return {
kind: 'draft',
doc: draftContinuedReply(position),
/* Never "continue" again — the reply *is* the continuation. */
followUp: draftActions(position),
};
case 'show_weights':
return { kind: 'draft', doc: weightsReply(position), followUp: draftActions(position) };
case 'explain_weights':
return { kind: 'draft', doc: explainWeightsReply(position), followUp: draftActions(position) };
case 'set_weights': {
const next = weightsFromQuestion(question, position);
if (!next) {
return { kind: 'draft', doc: weightsUnchangedReply(position), followUp: draftActions(position) };
}
return { kind: 'draft', perform: { action: 'set_weights', position, weights: next } };
}
case 'generate_description':
return { kind: 'draft', perform: { action: 'generate_description', position } };
case 'publish':
return position.status === 'draft'
? { kind: 'draft', perform: { action: 'publish', position } }
: {
kind: 'draft',
doc: doc(text(`**${position.title}** is already published.`)),
followUp: draftActions(position),
};
default:
return null;
}
}
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/* The record the control that raised this question was built from, when there
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
positionId = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/**
* 1. Finishing a draft, wherever the reader is standing.
*
* Ahead of everything else because these questions name a record and an act
* on it — continuing, generating, weighting, publishing — and every other
* matcher would read them as words about positions in general. It runs only
* where the panel actually has the positions to act on.
*/
/* Resolved even where the panel carries no workforce: a question about
continuing a draft is answered in the panel or answered as "there is no
draft", never handed to keyword routing — which is what used to send
"Continue the Bartender draft" into the authoring form, because Create
Position lists `position` and `role` among its topics. */
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
if (draftIntent) return draftIntent;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,

View File

@@ -10,6 +10,10 @@ import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import {
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
publishedReply, weightsSetReply, weightsUnchangedReply,
} from '@/lib/skills/draftFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
@@ -118,6 +122,58 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
*/
const AUTHORING_ROUTE = '/admin/positions/new';
/**
* The one wording every draft-continuation control resolves to.
*
* Named once because two things have to agree about it: the chip a suggestion
* writes today, and the chip a thread wrote months ago. Both must reach the
* same in-panel handler, so both are normalized to this phrasing rather than
* one being special-cased.
*/
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
/** The wording older builds used for the drafts question. */
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
/**
* A stored chip, brought up to the current draft behavior.
*
* Threads persist as the blocks and chips they rendered, so a conversation
* written before draft continuation moved into the panel still carries the
* controls of that era: a `capability` that runs the read-only drafts report,
* or a prompt phrased so loosely that keyword routing sent it to the authoring
* form. Rewriting them in code was not enough — the ones already written down
* had to change behavior too.
*
* Deliberately narrow. Only chips that are *about continuing a draft* are
* touched, and only their routing metadata: the label the reader saw is left
* exactly as it was, so a thread reads as it always did and simply does the
* right thing when clicked.
*/
function normalizeDraftChip(chip) {
if (!chip) return chip;
const label = String(chip.label || '');
const prompt = String(chip.prompt || '');
/* "Continue the Bartender draft", however it was stored: the label is the
question, and no capability or route may intercept it. */
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
const { capability, route, ...rest } = chip;
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
}
/* The old drafts question ran a capability that could only describe drafts
and told the reader to open the form. The same words now resolve to the
draft flow, which continues one or offers the several by name. */
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
const { capability, route, ...rest } = chip;
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
}
return chip;
}
/**
* A stored thread, with any completed-then-reopen action stripped out.
*
@@ -138,7 +194,9 @@ function withoutAuthoringActions(messages = []) {
return messages.map((message) => {
if (message.role !== 'assistant') return message;
const followUp = message.followUp?.filter((chip) => !leadsToForm(chip.route));
const followUp = message.followUp
?.filter((chip) => !leadsToForm(chip.route))
.map(normalizeDraftChip);
const blocks = message.blocks?.map((block) => (
block?.type === 'insights'
? {
@@ -168,6 +226,10 @@ function withoutAuthoringActions(messages = []) {
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
itself and there is one update path for a position. */
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/* The page's own name, recorded with an archived thread so History can say
@@ -281,7 +343,7 @@ export function useConversation({
const messagesRef = React.useRef(messages);
messagesRef.current = messages;
const send = React.useCallback(async ({ question, capability = null }) => {
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
const text = String(question).trim();
if (!text) return;
@@ -323,7 +385,7 @@ export function useConversation({
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce, skillContext,
courses, workforce, skillContext, positionId,
});
}
@@ -377,6 +439,59 @@ export function useConversation({
}
}
/**
* Finishing a draft. Same contract as every other write in this file: it
* happens before the reply, because the reply states the outcome.
*
* Each branch calls the mutation the form already uses and then says what
* is now true of the record — no second store, and no navigation.
*/
if (intent.kind === 'draft' && intent.perform) {
const { action, position } = intent.perform;
if (action === 'generate_description') {
let result = null;
try {
result = await onGenerateDescription?.(position);
} catch {
result = null;
}
intent = {
...intent,
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
};
}
if (action === 'set_weights') {
let saved = null;
try {
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
} catch {
saved = null;
}
intent = {
...intent,
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
followUp: draftActions(saved || position),
};
}
if (action === 'publish') {
let published = null;
try {
published = await onUpdatePosition?.(position.id, { status: 'active' });
} catch {
published = null;
}
intent = {
...intent,
doc: published ? publishedReply(position) : publishFailedReply(position),
followUp: published ? publishedFollowUp(published) : draftActions(position),
};
}
}
/**
* The workforce write, on the same terms as the one above: it happens
* before the reply, because the reply states the new counts and those have
@@ -481,13 +596,19 @@ export function useConversation({
}
} catch (e) {
if (e?.name !== 'AbortError') {
/* The reader gets one sentence; the developer gets the exception. A
capability that throws used to leave no trace anywhere, so "I could
not complete that" was the only evidence anything had gone wrong —
and it named neither the action nor the reason. */
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
setError('I could not complete that. Try again in a moment.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext]);