legacy preview', 'utf8');
+
+ try {
+ const response = await app.inject({
+ method: 'GET',
+ url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
+ });
+
+ assert.equal(response.statusCode, 200);
+ assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
+ assert.match(response.body, /legacy preview/);
+ } finally {
+ await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
+ await app.close();
+ }
+});
+
+test('resource manager preview restores encoded hash fragments in the file name', async () => {
+ const app = Fastify();
+ await registerResourceManagerRoutes(app);
+
+ const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
+ const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
+ await fs.writeFile(absolutePath, 'encoded hash preview', 'utf8');
+
+ try {
+ for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
+ const encodedPath = relativePath
+ .split(path.sep)
+ .map((segment, index, list) =>
+ index === list.length - 1
+ ? `${encodeURIComponent(segment)}${encodedSuffix}`
+ : encodeURIComponent(segment),
+ )
+ .join('/');
+ const response = await app.inject({
+ method: 'GET',
+ url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
+ });
+
+ assert.equal(response.statusCode, 200);
+ assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
+ assert.match(response.body, /encoded hash preview/);
+ }
+ } finally {
+ await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
+ await app.close();
+ }
+});
diff --git a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
index 283951e..5eac403 100644
--- a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
+++ b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
@@ -2,6 +2,7 @@ import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
+import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';
const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
const SPORTS_SEARCH_KEY = '5';
@@ -695,6 +696,7 @@ export type BaseballTicketBayAlertItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
+ ownerLabel?: string | null;
title: string;
eventDate: string;
team: string;
@@ -722,6 +724,7 @@ export type BaseballTicketBayAlertLogItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
+ ownerLabel?: string | null;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -902,6 +905,7 @@ function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
+ ownerLabel: null,
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
@@ -938,6 +942,7 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
+ ownerLabel: null,
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
@@ -949,6 +954,34 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
};
}
+async function attachOwnerLabels(items: T[]) {
+ const sharedTokenOwnerIds = Array.from(
+ new Set(
+ items
+ .filter((item) => item.ownerType === 'shared-token')
+ .map((item) => normalizeText(item.ownerId))
+ .filter(Boolean),
+ ),
+ );
+
+ if (!sharedTokenOwnerIds.length) {
+ return items;
+ }
+
+ const labelEntries = await Promise.all(
+ sharedTokenOwnerIds.map(async (tokenId) => {
+ const detail = await getSharedResourceTokenDetail(tokenId);
+ return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
+ }),
+ );
+ const labelMap = new Map(labelEntries);
+
+ return items.map((item) => ({
+ ...item,
+ ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
+ }));
+}
+
function formatTimeInKst(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Seoul',
@@ -1162,7 +1195,7 @@ export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerS
{ column: 'client_id', order: 'asc' },
{ column: 'created_at', order: 'desc' },
])) as BaseballTicketBayAlertRow[];
- return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
+ return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
}
export async function listBaseballTicketBayLogs(
@@ -1177,7 +1210,7 @@ export async function listBaseballTicketBayLogs(
}
const rows = (await query) as BaseballTicketBayLogRow[];
- return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
+ return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
}
export async function createBaseballTicketBayLog(args: {
@@ -1208,7 +1241,7 @@ export async function createBaseballTicketBayLog(args: {
payload_json: args.payload ? JSON.stringify(args.payload) : null,
};
await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
- return mapLogRow(row);
+ return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1234,7 +1267,7 @@ export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballT
scope,
).delete();
- return mapLogRow(existing as BaseballTicketBayLogRow);
+ return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
}
export async function createBaseballTicketBayAlert(
@@ -1269,20 +1302,26 @@ export async function createBaseballTicketBayAlert(
last_match_at: null,
};
await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
- return mapAlertRow(row);
+ return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
}
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial,
- context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
+ context:
+ | { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
+ | { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
+ const scope =
+ 'scope' in context
+ ? context.scope
+ : ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
- { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
+ scope,
).first();
if (!current) {
@@ -1309,18 +1348,15 @@ export async function updateBaseballTicketBayAlert(
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
- await applyOwnerScope(
- db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
- { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
- ).update(patch);
+ await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
- { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
+ scope,
).first();
- return mapAlertRow(updated as BaseballTicketBayAlertRow);
+ return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1340,7 +1376,7 @@ export async function deleteBaseballTicketBayAlert(alertId: string, scope: Baseb
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
- return mapAlertRow(row as BaseballTicketBayAlertRow);
+ return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
diff --git a/etc/servers/work-server/src/services/chat-message-parts.ts b/etc/servers/work-server/src/services/chat-message-parts.ts
index 29df983..9963267 100644
--- a/etc/servers/work-server/src/services/chat-message-parts.ts
+++ b/etc/servers/work-server/src/services/chat-message-parts.ts
@@ -105,9 +105,103 @@ function unwrapMarkdownLinkTarget(value: string) {
return matched?.[1]?.trim() ?? normalized;
}
+function normalizeResourceManagerPathSegment(segment: string) {
+ const normalized = normalizeText(segment);
+
+ if (!normalized) {
+ return '';
+ }
+
+ try {
+ return encodeURIComponent(decodeURIComponent(normalized));
+ } catch {
+ return encodeURIComponent(normalized);
+ }
+}
+
+function normalizeUrlFragmentValue(value: string) {
+ const normalized = normalizeText(value).replace(/^#+/, '');
+
+ if (!normalized) {
+ return '';
+ }
+
+ try {
+ return decodeURIComponent(normalized);
+ } catch {
+ return normalized;
+ }
+}
+
+function decodeRepeatedly(value: string, maxIterations = 3) {
+ let current = value;
+
+ for (let index = 0; index < maxIterations; index += 1) {
+ try {
+ const decoded = decodeURIComponent(current);
+
+ if (!decoded || decoded === current) {
+ break;
+ }
+
+ current = decoded;
+ } catch {
+ break;
+ }
+ }
+
+ return current;
+}
+
+function normalizePreviewPathHash(value: string) {
+ const normalized = normalizeText(value);
+
+ if (!normalized) {
+ return '';
+ }
+
+ const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
+ const isRootRelative = normalized.startsWith('/');
+
+ try {
+ const parsed = new URL(normalized, 'https://local.invalid');
+ const segments = parsed.pathname.split('/');
+ const lastSegment = segments.at(-1) ?? '';
+ const decodedLastSegment = decodeRepeatedly(lastSegment);
+ const hashIndex = decodedLastSegment.lastIndexOf('#');
+
+ if (hashIndex <= 0 || parsed.hash) {
+ return normalized;
+ }
+
+ const fileName = decodedLastSegment.slice(0, hashIndex).trim();
+ const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
+
+ if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
+ return normalized;
+ }
+
+ segments[segments.length - 1] = encodeURIComponent(fileName);
+ parsed.pathname = segments.join('/');
+ parsed.hash = fragment;
+
+ if (isAbsoluteUrl) {
+ return parsed.toString();
+ }
+
+ const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
+ return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
+ } catch {
+ return normalized;
+ }
+}
+
function buildResourceManagerPreviewUrl(value: string) {
- const normalized = normalizeText(value).replace(/\\/g, '/');
- const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
+ const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
+ const hashIndex = normalized.indexOf('#');
+ const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
+ const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
+ const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
@@ -123,10 +217,10 @@ function buildResourceManagerPreviewUrl(value: string) {
const encodedPath = relativePath
.split('/')
.filter(Boolean)
- .map((segment) => encodeURIComponent(segment))
+ .map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
- return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
+ return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function normalizeUrl(value: string) {
@@ -136,6 +230,17 @@ function normalizeUrl(value: string) {
return '';
}
+ try {
+ const parsed = new URL(normalized, 'https://local.invalid');
+ const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
+
+ if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
+ return normalizePreviewPathHash(pathname);
+ }
+ } catch {
+ // Fall through to handle relative and embedded resource paths below.
+ }
+
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;
diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts
index 74f2038..3f3eacc 100644
--- a/etc/servers/work-server/src/services/chat-service.test.ts
+++ b/etc/servers/work-server/src/services/chat-service.test.ts
@@ -1416,6 +1416,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
);
});
+test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
+ assert.deepEqual(
+ extractChatMessageParts(
+ '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
+ ),
+ {
+ strippedText: '',
+ parts: [
+ {
+ type: 'prompt',
+ title: '시안 선택',
+ description: null,
+ submitLabel: null,
+ mode: null,
+ multiple: false,
+ responseTemplate: null,
+ freeTextLabel: null,
+ freeTextPlaceholder: null,
+ currentStepKey: null,
+ readOnly: false,
+ selectedValues: [],
+ resolvedBy: null,
+ resolvedAt: null,
+ resultText: null,
+ steps: undefined,
+ options: [
+ {
+ label: 'A안',
+ value: 'option-a',
+ description: null,
+ preview: {
+ type: 'resource',
+ url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
+ content: null,
+ alt: null,
+ title: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ );
+});
+
+test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
+ assert.deepEqual(
+ extractChatMessageParts(
+ '[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
+ ),
+ {
+ strippedText: '',
+ parts: [
+ {
+ type: 'prompt',
+ title: '시안 선택',
+ description: null,
+ submitLabel: null,
+ mode: null,
+ multiple: false,
+ responseTemplate: null,
+ freeTextLabel: null,
+ freeTextPlaceholder: null,
+ currentStepKey: null,
+ readOnly: false,
+ selectedValues: [],
+ resolvedBy: null,
+ resolvedAt: null,
+ resultText: null,
+ steps: undefined,
+ options: [
+ {
+ label: 'A안',
+ value: 'option-a',
+ description: null,
+ preview: {
+ type: 'resource',
+ url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
+ content: null,
+ alt: null,
+ title: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ );
+});
+
test('extractChatMessageParts supports stepper prompt steps', () => {
assert.deepEqual(
extractChatMessageParts(
diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts
index 1108547..50b1128 100644
--- a/etc/servers/work-server/src/services/chat-service.ts
+++ b/etc/servers/work-server/src/services/chat-service.ts
@@ -36,6 +36,7 @@ import {
getSharedResourceTokenDetailBySharePath,
validateSharedResourceAccessPinBySharePath,
} from './shared-resource-token-service.js';
+import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js';
import { sendNotifications } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
@@ -794,10 +795,18 @@ async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL)
const sharedToken = sharedTokenDetail?.token ?? null;
const resourceContext = sharedToken?.resourceContext ?? null;
- if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) {
+ if (!resourceContext) {
return false;
}
+ if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) {
+ const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null;
+
+ if (!requestedRoom) {
+ return false;
+ }
+ }
+
if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
return false;
}
diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts
index 4b185d0..f775690 100644
--- a/etc/servers/work-server/src/services/notification-service.ts
+++ b/etc/servers/work-server/src/services/notification-service.ts
@@ -84,10 +84,14 @@ type NotificationPreferenceTarget = {
function normalizeTargetDeviceIds(payload: {
targetDeviceIds?: string[];
+}) {
+ return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
+}
+
+function normalizeTargetClientIds(payload: {
targetClientIds?: string[];
}) {
- const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
- return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
+ return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeRegistrationCleanupIds(...values: Array) {
@@ -126,18 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
-function isAllowedTargetDeviceId(
+function isAllowedTargetRecipient(
target: {
deviceId?: string;
+ clientId?: string;
},
targetDeviceIds: string[],
+ targetClientIds: string[],
) {
- if (targetDeviceIds.length === 0) {
+ if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
return true;
}
const deviceId = String(target.deviceId ?? '').trim();
- return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
+ const clientId = String(target.clientId ?? '').trim();
+ return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
}
function normalizeAppOrigin(value: unknown) {
@@ -917,6 +924,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const provider = await getProvider();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
+ const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
@@ -952,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
- isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
+ isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -1002,6 +1010,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
+ const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
@@ -1031,7 +1040,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
).filter(
(row) =>
row.allowed &&
- isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
+ isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({
diff --git a/etc/servers/work-server/src/services/resource-manager-service.ts b/etc/servers/work-server/src/services/resource-manager-service.ts
index 11cdb36..21f7690 100644
--- a/etc/servers/work-server/src/services/resource-manager-service.ts
+++ b/etc/servers/work-server/src/services/resource-manager-service.ts
@@ -63,6 +63,7 @@ class ResourceManagerError extends Error {
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
+const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
const TEXT_FILE_EXTENSIONS = new Set([
'.txt',
@@ -199,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
return normalized.replace(/^\/+/, '');
}
+function decodeRepeatedly(value: string, maxIterations = 3) {
+ let current = value;
+
+ for (let index = 0; index < maxIterations; index += 1) {
+ try {
+ const decoded = decodeURIComponent(current);
+
+ if (!decoded || decoded === current) {
+ break;
+ }
+
+ current = decoded;
+ } catch {
+ break;
+ }
+ }
+
+ return current;
+}
+
+function normalizePreviewTargetPath(targetPath: string) {
+ const normalized = normalizeRelativeTarget(targetPath);
+
+ if (!normalized) {
+ return normalized;
+ }
+
+ const segments = normalized.split('/');
+ const lastSegment = segments.at(-1) ?? '';
+ const decodedLastSegment = decodeRepeatedly(lastSegment);
+ const hashIndex = decodedLastSegment.lastIndexOf('#');
+
+ if (hashIndex <= 0) {
+ return normalized;
+ }
+
+ const fileName = decodedLastSegment.slice(0, hashIndex).trim();
+
+ if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
+ return normalized;
+ }
+
+ segments[segments.length - 1] = fileName;
+ return normalizeRelativeTarget(segments.join('/'));
+}
+
function resolveRepoRoot(candidateRootPath: string) {
const candidates = [
candidateRootPath,
@@ -221,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
}
+function resolveLegacyPublicResourceRoot(repoRootPath: string) {
+ return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
+}
+
+function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
+ const normalizedRelativePath = normalizeRelativeTarget(targetPath);
+
+ if (!normalizedRelativePath) {
+ throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
+ }
+
+ const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
+ const absolutePath = path.resolve(rootPath, normalizedRelativePath);
+
+ if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
+ throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
+ }
+
+ return {
+ absolutePath,
+ relativePath: normalizedRelativePath,
+ };
+}
+
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
const rootPath = resolveResourceManagerRoot(repoRootPath);
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
@@ -604,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
- const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
+ const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
+ const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
+ const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
+ const absolutePath = existsSync(primaryTarget.absolutePath)
+ ? primaryTarget.absolutePath
+ : legacyTarget.absolutePath;
if (!existsSync(absolutePath)) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md
new file mode 100644
index 0000000..8becf36
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/docs/feature-spec.md
@@ -0,0 +1,28 @@
+# 공유채팅방 개선
+
+## 변경 목적
+- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다.
+- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다.
+- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다.
+
+## 변경 범위
+- `src/app/main/mainChatPanel/ChatPromptCard.tsx`
+ - preview fetch effect 의존성을 안정화했습니다.
+ - preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다.
+ - preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다.
+- `src/app/main/pages/ChatSharePage.tsx`
+ - 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다.
+ - 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다.
+ - 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다.
+
+## 데이터 / API 영향
+- 새 저장소 키
+ - `sessionStorage`: `codex-live-share-room-snapshot::`
+- 기존 API 계약 변경 없음
+ - `/api/chat/shares/:token`
+ - `/api/chat/shares/:token?sessionId=...`
+
+## 확인 포인트
+- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지
+- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지
+- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png
new file mode 100644
index 0000000..f582fc9
Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-desktop-01.png differ
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png
new file mode 100644
index 0000000..db49856
Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/final-ui/ui-final-mobile-01.png differ
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png
new file mode 100644
index 0000000..f582fc9
Binary files /dev/null and b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-share-room-restore-01.png differ
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md
new file mode 100644
index 0000000..eabadca
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/공유채팅방 개선/20260528/verification/verification-summary.md
@@ -0,0 +1,24 @@
+# 공유채팅방 개선 검증
+
+## 실행 결과
+- `npm exec tsc --noEmit`
+ - 성공
+- `npm run build:test-app`
+ - 성공
+- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459`
+ - 성공
+ - 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5`
+
+## 브라우저 확인
+- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다.
+- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다.
+- 따라서 이번 검증 결론은 다음 범위로 한정합니다.
+ - 타입 오류 없음
+ - 프로덕션 테스트 빌드 성공
+ - 공유채팅 스냅샷 API 정상 응답
+ - 로컬 브라우저 진입 자체는 가능
+
+## 판정
+- 코드 변경은 정상 반영됨
+- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료
+- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md
new file mode 100644
index 0000000..0a1541f
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/docs/feature-spec.md
@@ -0,0 +1,27 @@
+# 공유채팅방 멈춤 완화
+
+## 변경 배경
+- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다.
+- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다.
+
+## 변경 내용
+- 기존 `sessionStorage` 제거 상태는 유지합니다.
+- `etc/servers/work-server/src/routes/chat.ts`
+ - 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다.
+ - 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다.
+- `src/app/main/pages/ChatSharePage.tsx`
+ - 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다.
+ - 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다.
+
+## 기대 효과
+- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다.
+- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다.
+
+## 영향 범위
+- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다.
+- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다.
+
+## 확인 포인트
+- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인
+- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인
+- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인
diff --git a/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md
new file mode 100644
index 0000000..d8e1e59
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/공유채팅방 멈춤 완화/20260528/verification/verification-summary.md
@@ -0,0 +1,15 @@
+# 검증 요약
+
+## 실행한 검증
+- `npm exec tsc --noEmit`
+- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit`
+- `npm run build:test-app`
+
+## 결과
+- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다.
+- 테스트 번들은 재빌드까지 통과했습니다.
+- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다.
+
+## 비고
+- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다.
+- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다.
diff --git a/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md
new file mode 100644
index 0000000..a81e778
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/docs/feature-spec.md
@@ -0,0 +1,20 @@
+# 공유채팅 채팅방 이동 소도 개선
+
+## 변경 요약
+- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다.
+- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다.
+- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다.
+- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다.
+
+## 변경 범위
+- 공유채팅 화면의 방 선택/복원/URL 동기화 로직
+- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원
+
+## 데이터 및 API 영향
+- 서버 API 스펙 변경은 없습니다.
+- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다.
+
+## 확인 포인트
+- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지
+- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지
+- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지
diff --git a/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md
new file mode 100644
index 0000000..e47e2df
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/채팅방 이동 소도 개선/20260528/verification/verification-summary.md
@@ -0,0 +1,11 @@
+# 검증 요약
+
+## 수행 내용
+- `npm exec tsc --noEmit`
+
+## 결과
+- 타입체크 통과
+
+## 미수행 항목
+- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다.
+- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다.
diff --git a/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html
new file mode 100644
index 0000000..1681ea9
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html
@@ -0,0 +1,709 @@
+
+
+
+
+
+ 공유채팅 헤더 재배치 제안
+
+
+
+
+
+ 공유채팅 실제 테마 기반 제안
+
채팅방 헤더를 방 목록 + 알림센터 허브로 재구성
+
+ 현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서
+ 헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로
+ iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다.
+
+
+ 현재 테마: #edf3fb → #e4edf8
+ 액션 톤: white pill + #2563eb
+ 재질감: blur + soft shadow
+ 상태칩: blue / green / amber / red
+
+
+
+
+
+
+
A안. Capsule Rail
+
가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.
+
+
+ 추천: 모바일 우선
+ 방 목록 발견성 높음
+ 알림센터 구분 명확
+
+
+
9:41Live 5G 92%
+
+
+
+
CC
+
+ 공유채팅 운영룸
+ 제목/아이콘 탭: 방 목록 + 필터 열기
+
+
+
설정
+
-
+
×
+
+
+
진행중 6 · 다른 방 새답변 4
+
apps 2건
+
+
+
+
+
+
+ 알림센터
+ 전체 채팅 + apps
+
+
+
+
+
12처리중 요청
+
4다른 방 새 답변
+
2apps 경고
+
1확인 필요 실패
+
+
+
+
+
방 목록 + 필터
+
+
+
전체 방18
+ 최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환
+
+
+
개발 운영방새 답변
+ 3분 전 Codex 응답 도착 · 앱 연결 2개
+
+
+
+
+
+
알림 피드
+
+
+
자동화 공유방진행중
+
배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.
+
+
+
Apps 알림권한
+
캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.
+
+
+
+
+
+
+
헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.
+
A안은 그 요구를 가장 직접적으로 충족합니다.
+
+
+
+
+
+
+
B안. Split Status Bar
+
좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.
+
+
+ 추천: 데스크톱 확장
+ 필터 접근 가장 빠름
+ 정보량 많음
+
+
+
9:41Workspace online
+
+
+
+
PM
+
+ 프로젝트 메인룸
+ 아이콘/제목 클릭: 방 전환
+
+
+
다른 방 4
+
apps 2
+
+
+
전체
+
진행중
+
안읽음
+
apps
+
+
+
+
+
+
+ Notification Center Dashboard
+ 실시간 집계
+
+
+
08현재 방 처리 흐름
+
03다른 방 확인대기
+
05apps 작업 알림
+
02실패/재시도
+
+
+
+
요약 콘텐츠
+
+
+
현재 방3건
+
작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치
+
+
+
다른 채팅방새 답변
+
현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌
+
+
+
Apps확인 필요
+
캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬
+
+
+
+
+
+
+
필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.
+
B안은 필터 중심 운영에 가장 유리합니다.
+
+
+
+
+
+
+
C안. Focus Stack
+
현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.
+
+
+ 추천: 집중 작업
+ 브랜드감 강함
+ 운영감시형 대시보드
+
+
+
9:41Preview theme sync
+
+
+
+
UX
+
+ UX 검토 채팅방
+ Hero chip 탭: 방 목록 / 필터 / 최근방
+
+
+
≡
+
+
+
현재 방 진행중 4
+
전체 알림 9
+
+
+
+
+
+
+ 집중 대시보드
+ 현재 방 우선
+
+
+
4현재 방 처리중
+
2완료 직전
+
3다른 방 확인
+
1apps 경고
+
+
+
+
우선순위 피드
+
+
+
현재 방 요청우선
+
마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.
+
+
+
다른 방 답변보조
+
메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.
+
+
+
Apps 이벤트연결
+
배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.
+
+
+
+
+
+
+
작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.
+
C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.
+
+
+
+
+
+
+ 추천 순서는 A안 → B안 → C안입니다. A안은 현재 공유채팅의 둥근 pill 액션과 blur 헤더 감성을 가장 자연스럽게 이어가면서,
+ “제목/아이콘으로 방 목록”, “손잡이로 알림센터”라는 두 행동을 가장 덜 헷갈리게 분리합니다.
+
+
+
+
diff --git a/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md
new file mode 100644
index 0000000..f4dfd99
--- /dev/null
+++ b/public/resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/feature-spec.md
@@ -0,0 +1,53 @@
+# 공유채팅 채팅방 헤더 재배치 제안
+
+## 목적
+- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다.
+- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다.
+- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다.
+
+## 현재 테마 확인 기준
+- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다.
+- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다.
+- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다.
+- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다.
+
+## 제안 방향 공통 원칙
+- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다.
+- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다.
+- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다.
+- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다.
+
+## 제안안 구성
+### A안 Capsule Rail
+- 제목 캡슐 자체가 방 목록 트리거다.
+- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다.
+- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다.
+
+### B안 Split Status Bar
+- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다.
+- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다.
+- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다.
+
+### C안 Focus Stack
+- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다.
+- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다.
+- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다.
+
+## 유지되어야 할 기존 기능
+- 방 이동
+- 필터 전환
+- 설정
+- 최소화
+- 닫기
+- 현재 연결 상태 표시
+- 현재 방 상태 요약
+
+## 알림센터 추천 콘텐츠
+- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업
+- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드
+- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글
+
+## 검토 포인트
+- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지
+- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지
+- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지
diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx
index 6bbcc39..e617fab 100644
--- a/src/app/main/AppShell.tsx
+++ b/src/app/main/AppShell.tsx
@@ -12,6 +12,7 @@ import { isPreviewRuntime } from './previewRuntime';
export function AppShell() {
return (
+ } />
} />
} />
}>
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx
index 5d980c4..95ea326 100644
--- a/src/app/main/MainHeader.tsx
+++ b/src/app/main/MainHeader.tsx
@@ -257,6 +257,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
left.receiveRoomNotifications === right.receiveRoomNotifications &&
+ left.guidePwaInstallForNotifications === right.guidePwaInstallForNotifications &&
+ left.guideWebPushPermission === right.guideWebPushPermission &&
+ left.guideWebPushRegistration === right.guideWebPushRegistration &&
left.restartReservationCompletionDelaySeconds === right.restartReservationCompletionDelaySeconds
);
}
@@ -284,6 +287,18 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
changedLabels.push('채팅방 알림 수신');
}
+ if (saved.guidePwaInstallForNotifications !== draft.guidePwaInstallForNotifications) {
+ changedLabels.push('PWA 설치 유도');
+ }
+
+ if (saved.guideWebPushPermission !== draft.guideWebPushPermission) {
+ changedLabels.push('웹푸시 권한 유도');
+ }
+
+ if (saved.guideWebPushRegistration !== draft.guideWebPushRegistration) {
+ changedLabels.push('웹푸시 등록 유도');
+ }
+
if (saved.restartReservationCompletionDelaySeconds !== draft.restartReservationCompletionDelaySeconds) {
changedLabels.push('재기동 예약 자동 실행 대기 시간');
}
@@ -3522,8 +3537,8 @@ export function MainHeader({
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
description={
chatSettingsDirty
- ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
- : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
+ ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfigDraft.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfigDraft.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfigDraft.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초`
+ : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, PWA 유도는 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도는 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도는 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초`
}
/>
@@ -3592,6 +3607,66 @@ export function MainHeader({
+
+ {
+ setAppConfigDraft((current) => ({
+ ...current,
+ chat: {
+ ...current.chat,
+ guidePwaInstallForNotifications: event.target.checked,
+ },
+ }));
+ }}
+ >
+ PWA 설치 유도
+
+
+ 브라우저 탭에서 알림 설정이 막히는 환경이면 홈 화면 앱으로 열도록 안내하는 개인 기본값입니다.
+
+
+
+
+ {
+ setAppConfigDraft((current) => ({
+ ...current,
+ chat: {
+ ...current.chat,
+ guideWebPushPermission: event.target.checked,
+ },
+ }));
+ }}
+ >
+ 웹푸시 권한 유도
+
+
+ 알림 권한이 미확인 상태면 브라우저 또는 기기 권한 허용을 먼저 안내하는 개인 기본값입니다.
+
+
+
+
+ {
+ setAppConfigDraft((current) => ({
+ ...current,
+ chat: {
+ ...current.chat,
+ guideWebPushRegistration: event.target.checked,
+ },
+ }));
+ }}
+ >
+ 웹푸시 등록 유도
+
+
+ 권한은 허용됐지만 현재 기기 구독이 없으면 웹푸시 등록까지 이어서 안내하는 개인 기본값입니다.
+
+